Skip to content

Commit

Permalink
Client implementation of useFormState
Browse files Browse the repository at this point in the history
This implements useFormState in Fiber. (It does not include any progressive
enhancement features; those will be added later.)

useFormState is a hook for tracking state produced by async actions. It has a
signature similar to useReducer, but instead of a reducer, it accepts an async
action function.

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

Calling dispatch runs the async action and updates the state to the
returned value.

Async actions run before React's render cycle, so unlike reducers, they can
contain arbitrary side effects.
  • Loading branch information
acdlite committed Aug 28, 2023
1 parent ed7275c commit a4a9273
Show file tree
Hide file tree
Showing 2 changed files with 405 additions and 18 deletions.
130 changes: 124 additions & 6 deletions packages/react-dom/src/__tests__/ReactDOMForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe('ReactDOMForm', () => {
let ReactDOMClient;
let Scheduler;
let assertLog;
let waitForThrow;
let useState;
let Suspense;
let startTransition;
Expand All @@ -50,6 +51,7 @@ describe('ReactDOMForm', () => {
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
assertLog = require('internal-test-utils').assertLog;
waitForThrow = require('internal-test-utils').waitForThrow;
useState = React.useState;
Suspense = React.Suspense;
startTransition = React.startTransition;
Expand Down Expand Up @@ -974,21 +976,137 @@ describe('ReactDOMForm', () => {

// @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;
test('useFormState updates state asynchronously and queues multiple actions', async () => {
let actionCounter = 0;
async function action(state, type) {
actionCounter++;

Scheduler.log(`Async action started [${actionCounter}]`);
await getText(`Wait [${actionCounter}]`);

switch (type) {
case 'increment':
return state + 1;
case 'decrement':
return state - 1;
default:
return state;
}
}

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

const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog([0]);
expect(container.textContent).toBe('0');

await act(() => dispatch('increment'));
assertLog(['Async action started [1]']);
expect(container.textContent).toBe('0');

// Dispatch a few more actions. None of these will start until the previous
// one finishes.
await act(() => dispatch('increment'));
await act(() => dispatch('decrement'));
await act(() => dispatch('increment'));
assertLog([]);

// Each action starts as soon as the previous one finishes.
// NOTE: React does not render in between these actions because they all
// update the same queue, which means they get entangled together. This is
// intentional behavior.
await act(() => resolveText('Wait [1]'));
assertLog(['Async action started [2]']);
await act(() => resolveText('Wait [2]'));
assertLog(['Async action started [3]']);
await act(() => resolveText('Wait [3]'));
assertLog(['Async action started [4]']);
await act(() => resolveText('Wait [4]'));

// Finally the last action finishes and we can render the result.
assertLog([2]);
expect(container.textContent).toBe('2');
});

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState supports inline actions', async () => {
let increment;
function App({stepSize}) {
const [state, dispatch] = useFormState(async prevState => {
return prevState + stepSize;
}, 0);
increment = dispatch;
return <Text text={state} />;
}

// Initial render
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App stepSize={1} />));
assertLog([0]);

// Perform an action. This will increase the state by 1, as defined by the
// stepSize prop.
await act(() => increment());
assertLog([1]);

// Now increase the stepSize prop to 10. Subsequent steps will increase
// by this amount.
await act(() => root.render(<App stepSize={10} />));
assertLog([1]);

// Increment again. The state should increase by 10.
await act(() => increment());
assertLog([11]);
});

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState: dispatch throws if called during render', async () => {
function App() {
const [state, dispatch] = useFormState(async () => {}, 0);
dispatch();
return <Text text={state} />;
}

const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
await waitForThrow('Cannot update form state while rendering.');
});
});

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState: warns if action is not async', async () => {
let dispatch;
function App() {
const [state, _dispatch] = useFormState(() => {}, 0);
dispatch = _dispatch;
return <Text text={state} />;
}

const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
});
assertLog([0]);

expect(() => {
// This throws because React expects the action to return a promise.
expect(() => dispatch()).toThrow('Cannot read properties of undefined');
}).toErrorDev(
[
// In dev we also log a warning.
'The action passed to useFormState must be an async function',
],
{withoutStack: true},
);
});
});
Loading

0 comments on commit a4a9273

Please sign in to comment.