Skip to content

Commit 64e85db

Browse files
rortan134TheEdoRan
andauthored
fix(hooks): prevent stale state access in callbacks (#377)
Previously, callbacks passed to ‎`useActionCallbacks` could become stale if their references changed between renders Co-authored-by: Edoardo Ranghieri <[email protected]>
1 parent c21b57d commit 64e85db

File tree

4 files changed

+90
-14
lines changed

4 files changed

+90
-14
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"use client";
2+
3+
import { StyledButton } from "@/app/_components/styled-button";
4+
import { StyledHeading } from "@/app/_components/styled-heading";
5+
import { useAction } from "next-safe-action/hooks";
6+
import { useState } from "react";
7+
import { ResultBox } from "../../_components/result-box";
8+
import { stateUpdateAction } from "./stateupdate-action";
9+
10+
export default function StateUpdate() {
11+
const [count, setCount] = useState(0);
12+
// Safe action (`deleteUser`) and optional callbacks passed to `useAction` hook.
13+
const { execute, result, status, reset } = useAction(stateUpdateAction, {
14+
onSuccess(args) {
15+
console.log("onSuccess callback:", args);
16+
console.log("Count value:", count);
17+
},
18+
onError(args) {
19+
console.log("onError callback:", args);
20+
},
21+
onNavigation(args) {
22+
console.log("onNavigation callback:", args);
23+
},
24+
onSettled(args) {
25+
console.log("onSettled callback:", args);
26+
},
27+
onExecute(args) {
28+
console.log("onExecute callback:", args);
29+
},
30+
});
31+
32+
return (
33+
<main className="w-96 max-w-full px-4">
34+
<StyledHeading>State update</StyledHeading>
35+
<div className="mt-4 flex flex-col gap-2">
36+
<StyledButton type="button" onClick={() => setCount(count + 1)}>
37+
Increment
38+
</StyledButton>
39+
<StyledButton type="button" onClick={() => setCount(count - 1)}>
40+
Decrement
41+
</StyledButton>
42+
<StyledButton type="button" onClick={() => execute()}>
43+
Execute
44+
</StyledButton>
45+
<StyledButton type="button" onClick={reset}>
46+
Reset
47+
</StyledButton>
48+
</div>
49+
<p className="mt-4">Count value: {count}</p>
50+
<ResultBox result={result} status={status} />
51+
</main>
52+
);
53+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"use server";
2+
3+
import { action } from "@/lib/safe-action";
4+
5+
export const stateUpdateAction = action.metadata({ actionName: "stateUpdateAction" }).action(async () => {
6+
await new Promise((res) => setTimeout(res, 1000));
7+
8+
return {
9+
message: "Hello, world!",
10+
};
11+
});

apps/playground/src/app/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default function Home() {
2121
<ExampleLink href="/stateful-form">
2222
Stateful form (<span className="font-mono">useActionState()</span> hook)
2323
</ExampleLink>
24+
<ExampleLink href="/state-update">State update</ExampleLink>
2425
<ExampleLink href="/navigation">Navigation</ExampleLink>
2526
<ExampleLink href="/file-upload">File upload</ExampleLink>
2627
<ExampleLink href="/bind-arguments">Bind arguments</ExampleLink>

packages/next-safe-action/src/hooks-utils.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ export const getActionShorthandStatusObject = (status: HookActionStatus): HookSh
5151
};
5252
};
5353

54+
/**
55+
* Converts a callback to a ref to avoid triggering re-renders when passed as a
56+
* prop or avoid re-executing effects when passed as a dependency
57+
*/
58+
function useCallbackRef<T extends (arg: any) => any>(callback: T | undefined): T {
59+
const callbackRef = React.useRef(callback);
60+
React.useEffect(() => {
61+
callbackRef.current = callback;
62+
});
63+
return React.useMemo(() => ((arg) => callbackRef.current?.(arg) as T) as T, []);
64+
}
65+
5466
export const useActionCallbacks = <ServerError, S extends StandardSchemaV1 | undefined, CVE, Data>({
5567
result,
5668
input,
@@ -66,20 +78,14 @@ export const useActionCallbacks = <ServerError, S extends StandardSchemaV1 | und
6678
navigationError: Error | null;
6779
thrownError: Error | null;
6880
}) => {
69-
const onExecuteRef = React.useRef(cb?.onExecute);
70-
const onSuccessRef = React.useRef(cb?.onSuccess);
71-
const onErrorRef = React.useRef(cb?.onError);
72-
const onSettledRef = React.useRef(cb?.onSettled);
73-
const onNavigationRef = React.useRef(cb?.onNavigation);
81+
const onExecute = useCallbackRef(cb?.onExecute);
82+
const onSuccess = useCallbackRef(cb?.onSuccess);
83+
const onError = useCallbackRef(cb?.onError);
84+
const onSettled = useCallbackRef(cb?.onSettled);
85+
const onNavigation = useCallbackRef(cb?.onNavigation);
7486

7587
// Execute the callback when the action status changes.
76-
React.useEffect(() => {
77-
const onExecute = onExecuteRef.current;
78-
const onSuccess = onSuccessRef.current;
79-
const onError = onErrorRef.current;
80-
const onSettled = onSettledRef.current;
81-
const onNavigation = onNavigationRef.current;
82-
88+
React.useLayoutEffect(() => {
8389
const executeCallbacks = async () => {
8490
switch (status) {
8591
case "executing":
@@ -99,7 +105,12 @@ export const useActionCallbacks = <ServerError, S extends StandardSchemaV1 | und
99105
break;
100106
case "hasErrored":
101107
await Promise.all([
102-
Promise.resolve(onError?.({ error: { ...result, ...(thrownError ? { thrownError } : {}) }, input })),
108+
Promise.resolve(
109+
onError?.({
110+
error: { ...result, ...(thrownError ? { thrownError } : {}) },
111+
input,
112+
})
113+
),
103114
Promise.resolve(onSettled?.({ result, input })),
104115
]);
105116
break;
@@ -128,5 +139,5 @@ export const useActionCallbacks = <ServerError, S extends StandardSchemaV1 | und
128139
};
129140

130141
executeCallbacks().catch(console.error);
131-
}, [input, status, result, navigationError, thrownError]);
142+
}, [input, status, result, navigationError, thrownError, onExecute, onSuccess, onSettled, onError, onNavigation]);
132143
};

0 commit comments

Comments
 (0)