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 23, 2023
1 parent 856dc5e commit 1a7c347
Show file tree
Hide file tree
Showing 15 changed files with 203 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';
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 @@ -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,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
21 changes: 21 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,23 @@ describe('ReactDOMForm', () => {
'A React form was unexpectedly submitted. If you called form.submit()',
);
});

// @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 @@ -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
Loading

0 comments on commit 1a7c347

Please sign in to comment.