react icon indicating copy to clipboard operation
react copied to clipboard

[React 19] No re-render after 'useActionState' action queue finishes

Open morditore opened this issue 1 year ago • 1 comments

Summary

When multiple client side actions are scheduled via useActionState, the "Action queue" promises are processed sequentially as expected. However after the last action promise resolves, the component is not re-rendered. This means, the component is stuck in "loading" without access to "data".

Steps to reproduce

  1. Open the demo here: https://codesandbox.io/p/sandbox/use-action-state-stuck-xl72xk?file=%2Fsrc%2FApp.js
  2. Click the "Send request" button two times.
  3. After 10 seconds (each request is 5 seconds and processed sequentially), the component still shows "Loading..." and not the dummy data (as I would expect).

Notes

  • When only one "action" is scheduled (button clicked once), the component re-renders when the action is done, as expected.
  • The promise "delay" seems to have an effect. When REQUEST_DELAY is set lower i.e. 1000ms, this "issue" is not present.
  • Possibly related: https://github.com/facebook/react/issues/27630

Is this behavior intentional?

morditore avatar Aug 14 '24 12:08 morditore

Can confirm. We do have tests covering those scenarios which pass oddly enough. I wasn't able to repro with real timers either in Jest.

eps1lon avatar Aug 16 '24 09:08 eps1lon

@morditore and @eps1lon here is a demo of bugfix: https://codesandbox.io/p/sandbox/use-action-state-stuck-forked-2y355f?workspaceId=cb6f0dff-d403-475d-ab82-61ee18a4ad69

it required an edit of ReactHooks.js #31001 The demo is used a simplified standalone version of ReactHooks.js that you can work in CodeSandbox without relying on internal React modules :D

lxmarinkovic avatar Sep 19 '24 11:09 lxmarinkovic

Unsure if I'm having the same issue (or rather, I'm pretty sure I'm hitting this issue, but may have other issues too). I'm calling an action that takes ~3.5 seconds to resolve, and returning a ReadableStream which sends some data more or less immediately, and the rest when the remote call succeeds (3.5s later).

If I call the action repeatedly, I'm hitting an error with calling getReader() (on the client side) repeatedly on the same stream (passed back in the state of the useActionState).

I don't see the useEffect that handles the state change being called repeatedly, and the server action executes only as they resolve (i.e. synchronously). It's unclear to me why I'm hitting this error (useLayoutEffect has the same behaviour, tracking the stream using a Map etc changes nothing).

Calling Code (typed manually to minimally reproduce, apologies if minor typos):

export const ComponentA = ({action}: {action: (data: FormData) => void}) => {
    ...
    const [actionFn1, actionFn2] = ['action1', 'action2'].map((t) => (d: FormData) => {
        startTransition(() => {
            d.append('type', t);
            console.log("Action triggered");
            action(d);
        });
    });

    return (
        ...
        <button id='preview' formAction={actionFn2}>Action 2</button>
        ...
    );
};

Page Code:

export const MyForm = () => {
    const [state, action] = useActionState(serverAction, { errorMessage: undefined });
    const [queuedElements, appendElement] = useReducer(....);

    const readElement = async (reader: ReadableStreamDefaultReader<StreamPreviewData>) => {
        try {
            while (true) {
                const { done, value } = await reader.read();
                if (done) { break; }
                appendElement(value);
            }
        } finally {
            reader.releaseLock();
        }
     };

    useEffect(() => {
        console.log('State changed:', state.streamedPreview);
        if (typeof state.streamedPreview === 'undefined') {
            return;
        }

        const { stream, id } = state.streamedPreview;
        if ( !streamsRef.current.has(id) ) {
            streamsRef.current.set(id, stream);
            
            // I added a transition here too, probably due to a lack of understanding of the rendering model....
            startTransition(() => {
                // This seems to error, but the consoles above don't print repeatedly, and the Map seems like it should protect
                const reader = stream.getReader(); 
                readElement(reader).finally(() => { streamsRef.current.delete(id) });
            });
        }
    }, [state.streamedPreview]);

   return (
      <form>
          ...
          <ComponentA action={action} />
      </form>
    );
};

server action:

export type FormState = {
    // other stuff
    streamedPreview?: {
        id: string;
        stream: ReadableStream<StreamPreviewData>;
    }
}

export const serverAction = async (state: FormState, data: FormData) => {
    console.log("server action"); // triggers the appropriate number of times, about 3.5s apart
    // blah blah processing
    const value = await new Promise<FormState>((resolve) => {
        const stream = new ReadableStream<StreamPreviewData>({
            async start(controller) {
                controller.enqueue({ .. skeleton trigger });
                const data = await slowFunction(...);
                controller.enqueue({ realData })
                controller.close();
            }
        });

        return resolve({
            streamedPreview: { id: createHash('md5').update(...).digest('hex'); stream }
        });
    }

    return value;
}

Was this issue ever addressed? Is there an appropriate workaround? I don't see anything on the pages for useActionState, useEffect (especially the caveats) or server functions that seem to indicate where I'm causing problems...

chrisb2244 avatar Jun 06 '25 02:06 chrisb2244

I am experiencing this or a similar issue after moving from next.js 15.4 to next.js 15.5 and this issues still exists in next.js 16.0.

On 15.4 the ui rerenders after the isPending state changes, but since 15.5 and higher it randomly sometimes works and sometimes does not work. This stops us from updating to next.js 16.0 which is a bummer.

LasseRosenow avatar Oct 22 '25 19:10 LasseRosenow

We're having an issue that fits perfectly @LasseRosenow's description and we can't migrate to next.js 16 either because of it. Is there anything I can do to help find and (maybe) fix the issue?

A bit more context on the code around where we have an issue. We use useTransition and not useActionState and we re-render the page with useRouter from nextJs when the action has finished running. Something like:

const [loading, startTransition] = useTransition();
const router = useRouter();

const onclick = () => startTransition(async () => {
  await serverActionCall();
  router.refresh();
})

After some troubleshooting, I found out that the commit that created the issue in NextJs is this one and looks like an update of react from [email protected] to [email protected]. Just in case it helps narrowing where this comes from

vinassefranche avatar Nov 10 '25 15:11 vinassefranche

I managed to create a reproduction repository I'm gonna open an issue on next.js repo as I don't know where the issue lies. edit: here's the issue on next's repo

vinassefranche avatar Nov 12 '25 20:11 vinassefranche