Skip to content

Commit

Permalink
Scaffolding for useFormState (facebook#27270)
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:

```js
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 authored and AndyPengc12 committed Apr 15, 2024
1 parent b9e170c commit f1aa04b
Show file tree
Hide file tree
Showing 15 changed files with 205 additions and 4 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
14 changes: 14 additions & 0 deletions packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,17 @@ export function useFormStatus(): FormStatus {
return dispatcher.useHostTransitionStatus();
}
}

export function useFormState<S, P>(
action: (S, P) => S,
initialState: S,
url?: string,
): [S, (P) => void] {
if (!(enableFormActions && enableAsyncActions)) {
throw new Error('Not implemented.');
} else {
const dispatcher = resolveDispatcher();
// $FlowFixMe[not-a-function] This is unstable, thus optional
return dispatcher.useFormState(action, initialState, url);
}
}
1 change: 1 addition & 0 deletions packages/react-dom/index.classic.fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
unstable_renderSubtreeIntoContainer,
unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
useFormStatus as experimental_useFormStatus,
useFormState as experimental_useFormState,
prefetchDNS,
preconnect,
preload,
Expand Down
1 change: 1 addition & 0 deletions packages/react-dom/index.experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
unstable_renderSubtreeIntoContainer,
unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
useFormStatus as experimental_useFormStatus,
useFormState as experimental_useFormState,
prefetchDNS,
preconnect,
preload,
Expand Down
1 change: 1 addition & 0 deletions packages/react-dom/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
unstable_renderSubtreeIntoContainer,
unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
useFormStatus as experimental_useFormStatus,
useFormState as experimental_useFormState,
prefetchDNS,
preconnect,
preload,
Expand Down
1 change: 1 addition & 0 deletions packages/react-dom/index.modern.fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
unstable_createEventHandle,
unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
useFormStatus as experimental_useFormStatus,
useFormState as experimental_useFormState,
prefetchDNS,
preconnect,
preload,
Expand Down
1 change: 1 addition & 0 deletions packages/react-dom/server-rendering-stub.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ export {
preload,
preinit,
experimental_useFormStatus,
experimental_useFormState,
unstable_batchedUpdates,
} from './src/server/ReactDOMServerRenderingStub';
24 changes: 24 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 @@ -31,6 +32,7 @@ describe('ReactDOMFizzForm', () => {
ReactDOMServer = require('react-dom/server.browser');
ReactDOMClient = require('react-dom/client');
useFormStatus = require('react-dom').experimental_useFormStatus;
useFormState = require('react-dom').experimental_useFormState;
useOptimistic = require('react').experimental_useOptimistic;
act = require('internal-test-utils').act;
container = document.createElement('div');
Expand Down Expand Up @@ -470,6 +472,28 @@ describe('ReactDOMFizzForm', () => {
expect(container.textContent).toBe('hi');
});

// @gate enableFormActions
// @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
22 changes: 22 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('ReactDOMForm', () => {
let startTransition;
let textCache;
let useFormStatus;
let useFormState;

beforeEach(() => {
jest.resetModules();
Expand All @@ -53,6 +54,7 @@ describe('ReactDOMForm', () => {
Suspense = React.Suspense;
startTransition = React.startTransition;
useFormStatus = ReactDOM.experimental_useFormStatus;
useFormState = ReactDOM.experimental_useFormState;
container = document.createElement('div');
document.body.appendChild(container);

Expand Down Expand Up @@ -969,4 +971,24 @@ describe('ReactDOMForm', () => {
'A React form was unexpectedly submitted. If you called form.submit()',
);
});

// @gate enableFormActions
// @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 = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog([0]);
expect(container.textContent).toBe('0');
});
});
5 changes: 4 additions & 1 deletion packages/react-dom/src/client/ReactDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ export {
preinit,
preinitModule,
} from '../shared/ReactDOMFloat';
export {useFormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
export {
useFormStatus,
useFormState,
} from 'react-dom-bindings/src/shared/ReactDOMFormActions';

if (__DEV__) {
if (
Expand Down
5 changes: 4 additions & 1 deletion packages/react-dom/src/server/ReactDOMServerRenderingStub.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export {
preconnect,
prefetchDNS,
} from '../shared/ReactDOMFloat';
export {useFormStatus as experimental_useFormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
export {
useFormStatus as experimental_useFormStatus,
useFormState as experimental_useFormState,
} from 'react-dom-bindings/src/shared/ReactDOMFormActions';

export function createPortal() {
throw new Error(
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 @@ -2981,6 +3012,7 @@ if (enableUseEffectEventHook) {
if (enableFormActions && enableAsyncActions) {
(ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus =
throwInvalidHookError;
(ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError;
}
if (enableAsyncActions) {
(ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError;
Expand Down Expand Up @@ -3018,6 +3050,7 @@ if (enableUseEffectEventHook) {
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
(HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState;
}
if (enableAsyncActions) {
(HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic;
Expand Down Expand Up @@ -3055,6 +3088,7 @@ if (enableUseEffectEventHook) {
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
(HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState;
}
if (enableAsyncActions) {
(HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic;
Expand Down Expand Up @@ -3092,6 +3126,7 @@ if (enableUseEffectEventHook) {
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
(HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState;
}
if (enableAsyncActions) {
(HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic;
Expand Down Expand Up @@ -3276,6 +3311,16 @@ if (__DEV__) {
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
(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);
};
}
if (enableAsyncActions) {
(HooksDispatcherOnMountInDEV: Dispatcher).useOptimistic =
Expand Down Expand Up @@ -3436,6 +3481,16 @@ if (__DEV__) {
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
(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);
};
}
if (enableAsyncActions) {
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useOptimistic =
Expand Down Expand Up @@ -3598,6 +3653,16 @@ if (__DEV__) {
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
(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);
};
}
if (enableAsyncActions) {
(HooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic =
Expand Down Expand Up @@ -3760,6 +3825,16 @@ if (__DEV__) {
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
(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);
};
}
if (enableAsyncActions) {
(HooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic =
Expand Down Expand Up @@ -3943,6 +4018,17 @@ if (__DEV__) {
if (enableFormActions && enableAsyncActions) {
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
(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);
};
}
if (enableAsyncActions) {
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useOptimistic =
Expand Down Expand Up @@ -4130,6 +4216,17 @@ if (__DEV__) {
if (enableFormActions && enableAsyncActions) {
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
(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);
};
}
if (enableAsyncActions) {
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic =
Expand Down Expand Up @@ -4317,6 +4414,17 @@ if (__DEV__) {
if (enableFormActions && enableAsyncActions) {
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
(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);
};
}
if (enableAsyncActions) {
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic =
Expand Down
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
Loading

0 comments on commit f1aa04b

Please sign in to comment.