Skip to content

Commit

Permalink
Scaffolding for useFormState
Browse files Browse the repository at this point in the history
This exposes, but does not yet implement, a new experimental API called
useFormState. It's gated behind the enableAsyncActions flag.

useFormState has a similar signature to useReducer, except instead of a reducer
it accepts an (async) action function. React will wait until the promise
resolves before updating the state:

  async function action(prevState, payload) {
    // ..
  }
  const [state, dispatch] = useFormState(action, initialState)

When used in combination with Server Actions, it will also support progressive
enhancement — a form that is submitted before it has hydrated will have its
state transferred to the next page. However, like the other action-related
hooks, it works with fully client-driven actions, too.
  • Loading branch information
acdlite committed Aug 22, 2023
1 parent dd480ef commit a114bc3
Show file tree
Hide file tree
Showing 13 changed files with 192 additions and 2 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ module.exports = {
$ReadOnlyArray: 'readonly',
$ArrayBufferView: 'readonly',
$Shape: 'readonly',
ReturnType: 'readonly',
AnimationFrameID: 'readonly',
// For Flow type annotation. Only `BigInt` is valid at runtime.
bigint: 'readonly',
Expand Down
23 changes: 23 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ let ReactDOMServer;
let ReactDOMClient;
let useFormStatus;
let useOptimistic;
let useFormState;

describe('ReactDOMFizzForm', () => {
beforeEach(() => {
Expand All @@ -32,6 +33,7 @@ describe('ReactDOMFizzForm', () => {
ReactDOMClient = require('react-dom/client');
useFormStatus = require('react-dom').experimental_useFormStatus;
useOptimistic = require('react').experimental_useOptimistic;
useFormState = require('react').experimental_useFormState;
act = require('internal-test-utils').act;
container = document.createElement('div');
document.body.appendChild(container);
Expand Down Expand Up @@ -470,6 +472,27 @@ describe('ReactDOMFizzForm', () => {
expect(container.textContent).toBe('hi');
});

// @gate enableAsyncActions
it('useFormState returns initial state', async () => {
async function action(state) {
return state;
}

function App() {
const [state] = useFormState(action, 0);
return state;
}

const stream = await ReactDOMServer.renderToReadableStream(<App />);
await readIntoContainer(stream);
expect(container.textContent).toBe('0');

await act(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
});
expect(container.textContent).toBe('0');
});

// @gate enableFormActions
it('can provide a custom action on the server for actions', async () => {
const ref = React.createRef();
Expand Down
108 changes: 108 additions & 0 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -1835,6 +1835,37 @@ function rerenderOptimistic<S, A>(
return [passthrough, dispatch];
}

function TODO_formStateDispatch() {
throw new Error('Not implemented.');
}

function mountFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
// TODO: Not yet implemented
return [initialState, TODO_formStateDispatch];
}

function updateFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
// TODO: Not yet implemented
return [initialState, TODO_formStateDispatch];
}

function rerenderFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
// TODO: Not yet implemented
return [initialState, TODO_formStateDispatch];
}

function pushEffect(
tag: HookFlags,
create: () => (() => void) | void,
Expand Down Expand Up @@ -2984,6 +3015,7 @@ if (enableFormActions && enableAsyncActions) {
}
if (enableAsyncActions) {
(ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError;
(ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError;
}

const HooksDispatcherOnMount: Dispatcher = {
Expand Down Expand Up @@ -3021,6 +3053,7 @@ if (enableFormActions && enableAsyncActions) {
}
if (enableAsyncActions) {
(HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic;
(HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState;
}

const HooksDispatcherOnUpdate: Dispatcher = {
Expand Down Expand Up @@ -3058,6 +3091,7 @@ if (enableFormActions && enableAsyncActions) {
}
if (enableAsyncActions) {
(HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic;
(HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState;
}

const HooksDispatcherOnRerender: Dispatcher = {
Expand Down Expand Up @@ -3095,6 +3129,7 @@ if (enableFormActions && enableAsyncActions) {
}
if (enableAsyncActions) {
(HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic;
(HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState;
}

let HooksDispatcherOnMountInDEV: Dispatcher | null = null;
Expand Down Expand Up @@ -3287,6 +3322,16 @@ if (__DEV__) {
mountHookTypesDev();
return mountOptimistic(passthrough, reducer);
};
(HooksDispatcherOnMountInDEV: Dispatcher).useFormState =
function useFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
mountHookTypesDev();
return mountFormState(action, initialState, url);
};
}

HooksDispatcherOnMountWithHookTypesInDEV = {
Expand Down Expand Up @@ -3447,6 +3492,16 @@ if (__DEV__) {
updateHookTypesDev();
return mountOptimistic(passthrough, reducer);
};
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useFormState =
function useFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
updateHookTypesDev();
return mountFormState(action, initialState, url);
};
}

HooksDispatcherOnUpdateInDEV = {
Expand Down Expand Up @@ -3609,6 +3664,16 @@ if (__DEV__) {
updateHookTypesDev();
return updateOptimistic(passthrough, reducer);
};
(HooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
function useFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
updateHookTypesDev();
return updateFormState(action, initialState, url);
};
}

HooksDispatcherOnRerenderInDEV = {
Expand Down Expand Up @@ -3771,6 +3836,16 @@ if (__DEV__) {
updateHookTypesDev();
return rerenderOptimistic(passthrough, reducer);
};
(HooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
function useFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
updateHookTypesDev();
return rerenderFormState(action, initialState, url);
};
}

InvalidNestedHooksDispatcherOnMountInDEV = {
Expand Down Expand Up @@ -3955,6 +4030,17 @@ if (__DEV__) {
mountHookTypesDev();
return mountOptimistic(passthrough, reducer);
};
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useFormState =
function useFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
warnInvalidHookAccess();
mountHookTypesDev();
return mountFormState(action, initialState, url);
};
}

InvalidNestedHooksDispatcherOnUpdateInDEV = {
Expand Down Expand Up @@ -4142,6 +4228,17 @@ if (__DEV__) {
updateHookTypesDev();
return updateOptimistic(passthrough, reducer);
};
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
function useFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
warnInvalidHookAccess();
updateHookTypesDev();
return updateFormState(action, initialState, url);
};
}

InvalidNestedHooksDispatcherOnRerenderInDEV = {
Expand Down Expand Up @@ -4329,5 +4426,16 @@ if (__DEV__) {
updateHookTypesDev();
return rerenderOptimistic(passthrough, reducer);
};
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
function useFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
warnInvalidHookAccess();
updateHookTypesDev();
return rerenderFormState(action, initialState, url);
};
}
}
8 changes: 7 additions & 1 deletion packages/react-reconciler/src/ReactInternalTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ export type HookType =
| 'useSyncExternalStore'
| 'useId'
| 'useCacheRefresh'
| 'useOptimistic';
| 'useOptimistic'
| 'useFormState';

export type ContextDependency<T> = {
context: ReactContext<T>,
Expand Down Expand Up @@ -413,6 +414,11 @@ export type Dispatcher = {
passthrough: S,
reducer: ?(S, A) => S,
) => [S, (A) => void],
useFormState?: <S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
) => [S, (P) => void],
};

export type CacheDispatcher = {
Expand Down
21 changes: 21 additions & 0 deletions packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ let assertLog;
let useTransition;
let useState;
let useOptimistic;
let useFormState;
let textCache;

describe('ReactAsyncActions', () => {
Expand All @@ -20,6 +21,7 @@ describe('ReactAsyncActions', () => {
useTransition = React.useTransition;
useState = React.useState;
useOptimistic = React.experimental_useOptimistic;
useFormState = React.experimental_useFormState;

textCache = new Map();
});
Expand Down Expand Up @@ -1074,4 +1076,23 @@ describe('ReactAsyncActions', () => {
</>,
);
});

// @gate enableAsyncActions
test('useFormState exists', async () => {
// TODO: Not yet implemented. This just tests that the API is wired up.

async function action(state) {
return state;
}

function App() {
const [state] = useFormState(action, 0);
return <Text text={state} />;
}

const root = ReactNoop.createRoot();
await act(() => root.render(<App />));
assertLog([0]);
expect(root).toMatchRenderedOutput('0');
});
});
14 changes: 14 additions & 0 deletions packages/react-server/src/ReactFizzHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,10 @@ function unsupportedSetOptimisticState() {
throw new Error('Cannot update optimistic state while rendering.');
}

function unsupportedDispatchFormState() {
throw new Error('Cannot update form state while rendering.');
}

function useOptimistic<S, A>(
passthrough: S,
reducer: ?(S, A) => S,
Expand All @@ -550,6 +554,15 @@ function useOptimistic<S, A>(
return [passthrough, unsupportedSetOptimisticState];
}

function useFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
resolveCurrentlyRenderingComponent();
return [initialState, unsupportedDispatchFormState];
}

function useId(): string {
const task: Task = (currentlyRenderingTask: any);
const treeId = getTreeId(task.treeContext);
Expand Down Expand Up @@ -650,6 +663,7 @@ if (enableFormActions && enableAsyncActions) {
}
if (enableAsyncActions) {
HooksDispatcher.useOptimistic = useOptimistic;
HooksDispatcher.useFormState = useFormState;
}

export let currentResponseState: null | ResponseState = (null: any);
Expand Down
1 change: 1 addition & 0 deletions packages/react/index.classic.fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export {
useInsertionEffect,
useMemo,
experimental_useOptimistic,
experimental_useFormState,
useReducer,
useRef,
useState,
Expand Down
1 change: 1 addition & 0 deletions packages/react/index.experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export {
useLayoutEffect,
useMemo,
experimental_useOptimistic,
experimental_useFormState,
useReducer,
useRef,
useState,
Expand Down
1 change: 1 addition & 0 deletions packages/react/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export {
useLayoutEffect,
useMemo,
experimental_useOptimistic,
experimental_useFormState,
useSyncExternalStore,
useReducer,
useRef,
Expand Down
1 change: 1 addition & 0 deletions packages/react/index.modern.fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export {
useLayoutEffect,
useMemo,
experimental_useOptimistic,
experimental_useFormState,
useReducer,
useRef,
useState,
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/React.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
use,
useMemoCache,
useOptimistic,
useFormState,
} from './ReactHooks';
import {
createElementWithValidation,
Expand Down Expand Up @@ -112,6 +113,7 @@ export {
useLayoutEffect,
useMemo,
useOptimistic as experimental_useOptimistic,
useFormState as experimental_useFormState,
useSyncExternalStore,
useReducer,
useRef,
Expand Down
Loading

0 comments on commit a114bc3

Please sign in to comment.