Skip to content

Commit

Permalink
refactor(hooks): use separate type for server state in `useOptimistic…
Browse files Browse the repository at this point in the history
…Action` (#134)

This PR improves the design of the `useOptimisticAction` hook. Previously, the `optimisticData` had to be of the same type of action's return type. This isn't great, because the only job of a Server Action in a optimistic workflow is to mutate the data on the server. The actual state (and type) that matters is the one coming from the parent Server Component, so `updateFn`'s `prevState` should match that type. The actual result is still available in hooks callbacks and `result` property returned from the hook, though.

re #127
  • Loading branch information
TheEdoRan committed May 18, 2024
1 parent 1fbe49c commit c38dbe1
Show file tree
Hide file tree
Showing 5 changed files with 88 additions and 94 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,36 @@ import { ActionError, action } from "@/lib/safe-action";
import { revalidatePath } from "next/cache";
import { z } from "zod";

let likes = 42;

export const getLikes = async () => likes;

const incrementLikes = (by: number) => {
likes += by;
return likes;
};

const schema = z.object({
incrementBy: z.number(),
id: z.string().uuid(),
body: z.string().min(1),
completed: z.boolean(),
});

export const addLikes = action
.metadata({ actionName: "addLikes" })
export type Todo = z.infer<typeof schema>;

let todos: Todo[] = [];
export const getTodos = async () => todos;

export const addTodo = action
.metadata({ actionName: "" })
.schema(schema)
.action(async ({ parsedInput: { incrementBy } }) => {
.action(async ({ parsedInput }) => {
await new Promise((res) => setTimeout(res, 500));

if (Math.random() > 0.5) {
throw new ActionError(
"Could not update likes right now, please try again later."
"Could not add todo right now, please try again later."
);
}

const likesCount = incrementLikes(incrementBy);
todos.push(parsedInput);

// This Next.js function revalidates the provided path.
// More info here: https://nextjs.org/docs/app/api-reference/functions/revalidatePath
revalidatePath("/optimistic-hook");

return {
likesCount,
newTodo: parsedInput,
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ import { StyledButton } from "@/app/_components/styled-button";
import { StyledInput } from "@/app/_components/styled-input";
import { useOptimisticAction } from "next-safe-action/hooks";
import { ResultBox } from "../../_components/result-box";
import { addLikes } from "./addlikes-action";
import { Todo, addTodo } from "./addtodo-action";

type Props = {
likesCount: number;
todos: Todo[];
};

const AddLikesForm = ({ likesCount }: Props) => {
// Here we pass safe action (`addLikes`) and current server data to `useOptimisticAction` hook.
const { execute, result, status, reset, optimisticData } =
useOptimisticAction(addLikes, {
currentData: { likesCount },
updateFn: (prevData, { incrementBy }) => ({
likesCount: prevData.likesCount + incrementBy,
const AddTodoForm = ({ todos }: Props) => {
// Here we pass safe action (`addTodo`) and current server data to `useOptimisticAction` hook.
const { execute, result, status, reset, optimisticState } =
useOptimisticAction(addTodo, {
currentState: { todos },
updateFn: (prevState, newTodo) => ({
todos: [...prevState.todos, newTodo],
}),
onSuccess({ data, input }) {
console.log("HELLO FROM ONSUCCESS", data, input);
Expand All @@ -41,35 +41,25 @@ const AddLikesForm = ({ likesCount }: Props) => {
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const input = Object.fromEntries(formData) as {
incrementBy: string;
};

const intIncrementBy = parseInt(input.incrementBy);
const body = formData.get("body") as string;

// Action call. Here we pass action input and expected (optimistic)
// data.
execute({ incrementBy: intIncrementBy });
execute({ id: crypto.randomUUID(), body, completed: false });
}}>
<StyledInput
type="text"
name="incrementBy"
id="incrementBy"
placeholder="Increment by"
/>
<StyledButton type="submit">Add likes</StyledButton>
<StyledInput type="text" name="body" placeholder="Todo body" />
<StyledButton type="submit">Add todo</StyledButton>
<StyledButton type="button" onClick={reset}>
Reset
</StyledButton>
</form>
<ResultBox
result={optimisticData}
result={optimisticState}
status={status}
customTitle="Optimistic data:"
/>
<ResultBox result={result} customTitle="Actual result:" />
</>
);
};

export default AddLikesForm;
export default AddTodoForm;
11 changes: 4 additions & 7 deletions apps/playground/src/app/(examples)/optimistic-hook/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import { StyledHeading } from "@/app/_components/styled-heading";
import { getLikes } from "./addlikes-action";
import AddLikeForm from "./addlikes-form";
import { getTodos } from "./addtodo-action";
import AddTodoForm from "./addtodo-form";

export default async function OptimisticHookPage() {
const likesCount = await getLikes();
const todos = await getTodos();

return (
<main className="w-96 max-w-full px-4">
<StyledHeading>Action using optimistic hook</StyledHeading>
<pre className="mt-4 text-center">
Server data: {JSON.stringify(likesCount)}
</pre>
{/* Pass the server state to Client Component */}
<AddLikeForm likesCount={likesCount} />
<AddTodoForm todos={todos} />
</main>
);
}
15 changes: 8 additions & 7 deletions packages/next-safe-action/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,20 +205,21 @@ export const useOptimisticAction = <
FVE,
FBAVE,
Data,
State,
>(
safeActionFn: HookSafeActionFn<ServerError, S, BAS, FVE, FBAVE, Data>,
utils: {
currentData: Data;
updateFn: (prevData: Data, input: S extends Schema ? InferIn<S> : undefined) => Data;
currentState: State;
updateFn: (prevState: State, input: S extends Schema ? InferIn<S> : undefined) => State;
} & HookCallbacks<ServerError, S, BAS, FVE, FBAVE, Data>
) => {
const [, startTransition] = React.useTransition();
const [result, setResult] = React.useState<HookResult<ServerError, S, BAS, FVE, FBAVE, Data>>(EMPTY_HOOK_RESULT);
const [clientInput, setClientInput] = React.useState<S extends Schema ? InferIn<S> : void>();
const [isExecuting, setIsExecuting] = React.useState(false);
const [isIdle, setIsIdle] = React.useState(true);
const [optimisticData, setOptimisticData] = React.useOptimistic<Data, S extends Schema ? InferIn<S> : undefined>(
utils.currentData,
const [optimisticState, setOptimisticState] = React.useOptimistic<State, S extends Schema ? InferIn<S> : undefined>(
utils.currentState,
utils.updateFn
);

Expand All @@ -231,7 +232,7 @@ export const useOptimisticAction = <
setIsExecuting(true);

return startTransition(() => {
setOptimisticData(input as S extends Schema ? InferIn<S> : undefined);
setOptimisticState(input as S extends Schema ? InferIn<S> : undefined);
return safeActionFn(input as S extends Schema ? InferIn<S> : undefined)
.then((res) => setResult(res ?? EMPTY_HOOK_RESULT))
.catch((e) => {
Expand All @@ -246,7 +247,7 @@ export const useOptimisticAction = <
});
});
},
[safeActionFn, setOptimisticData]
[safeActionFn, setOptimisticState]
);

const reset = () => {
Expand All @@ -271,7 +272,7 @@ export const useOptimisticAction = <
execute,
input: clientInput,
result,
optimisticData,
optimisticState,
reset,
status,
...getActionShorthandStatusObject(status),
Expand Down
86 changes: 47 additions & 39 deletions website/docs/execution/hooks/useoptimisticaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,75 +9,86 @@ description: Learn how to use the useOptimisticAction hook.
`useOptimisticAction` **does not wait** for the action to finish execution before returning the optimistic data. It is then synced with the real result from server when the action has finished its execution. If you need to perform normal mutations, use [`useAction`](/docs/execution/hooks/useaction) instead.
:::

Let's say you want to update the number of likes of a post in your application, mutating directly the database.
Let's say you have some todos in your database and want to add a new one. The following example shows how you can use `useOptimisticAction` to add a new todo item optimistically.

### Example

1. Define a new action called `addLikes`, that takes an amount as input and returns the updated number of likes:
1. Define a new action called `addTodo`, that takes a `Todo` object as input:

```typescript title=src/app/add-likes-action.ts
```typescript title=src/app/addtodo-action.ts
"use server";

import { ActionError, action } from "@/lib/safe-action";
import { revalidatePath } from "next/cache";
import { z } from "zod";

const schema = z.object({
incrementBy: z.number().positive(),
id: z.string().uuid(),
body: z.string().min(1),
completed: z.boolean(),
});

// Fake database.
let likes = 42;
export const getLikes = () => likes;
export type Todo = z.infer<typeof schema>;

let todos: Todo[] = [];
export const getTodos = async () => todos;

export const addLikes = actionClient
export const addTodo = action
.metadata({ actionName: "" })
.schema(schema)
.action(async ({ parsedInput: { incrementBy } }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
.action(async ({ parsedInput }) => {
await new Promise((res) => setTimeout(res, 500));

// Mutate data in fake db. This would be a database call in the real world.
likes += incrementBy;
todos.push(parsedInput);

// We use this function to revalidate server state.
// More information about it here:
// https://nextjs.org/docs/app/api-reference/functions/revalidatePath
revalidatePath("/");
// This Next.js function revalidates the provided path.
// More info here: https://nextjs.org/docs/app/api-reference/functions/revalidatePath
revalidatePath("/optimistic-hook");

return { likesCount: likes };
return {
newTodo: parsedInput,
};
});

```

2. Then, in your Server Component, you need to pass the current number of likes to the Client Component:
2. Then, in the parent Server Component, you need to pass the current todos state to the Client Component:

```tsx title=src/app/page.tsx
import { getTodos } from "./addtodo-action";

export default function Home() {
return (
<main>
{/* Here we pass current number of likes to the Client Component.
{/* Here we pass current todos to the Client Component.
This is updated on the server every time the action is executed, since we
used `revalidatePath()` inside action's server code. */}
<AddLikes likesCount={getLikes()} />
<TodosBox todos={getTodos()} />
</main>
);
}
```

3. Finally, in your Client Component, you can use it like this:

```tsx title=src/app/add-likes.tsx
```tsx title=src/app/todos-box.tsx
"use client";

import { useOptimisticAction } from "next-safe-action/hooks";
import { addLikes } from "@/app/add-likes-action";
import { addTodo, type Todo } from "@/app/addtodo-action";

type Props = {
likesCount: number;
todos: Todo[];
};

export default function AddLikes({ likesCount }: Props) {
const { execute, result, optimisticData } = useOptimisticAction(
addLikes,
export default function TodosBox({ todos }: Props) {
const { execute, result, optimisticState } = useOptimisticAction(
addTodo,
{
currentData: { likesCount }, // gets passed from Server Component
updateFn: (prevData, { incrementBy }) => {
currentState: { todos }, // gets passed from Server Component
updateFn: (prevState, newTodo) => {
return {
likesCount: prevData.likesCount + amount
todos: [...prevState.todos, newTodo]
};
}
}
Expand All @@ -87,15 +98,12 @@ export default function AddLikes({ likesCount }: Props) {
<div>
<button
onClick={() => {
execute({ incrementBy: 3 });
execute({ id: crypto.randomUUID(), body: "New Todo", completed: false });
}}>
Add 3 likes
Add todo
</button>
{/* Optimistic state gets updated immediately, it doesn't wait for the server to respond. */}
<pre>Optimistic data: {optimisticData}</pre>

{/* Here's the actual server response. */}
<pre>Result: {JSON.stringify(result, null, 2)}</pre>
<pre>Optimistic state: {optimisticState}</pre>
</div>
);
}
Expand All @@ -108,14 +116,14 @@ export default function AddLikes({ likesCount }: Props) {
| Name | Type | Purpose |
| ----------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `safeActionFn` | [`HookSafeActionFn`](/docs/types#hooksafeactionfn) | This is the action that will be called when you use `execute` from hook's return object. |
| `utils` | `{ initData: Data; updateFn: (prevData: Data, input: InferIn<S>) => Data }` `&` [`HookCallbacks`](/docs/types#hookcallbacks) | Object with required `initData`, `updateFn` and optional callbacks. See below for more information. |
| `utils` | `{ currentState: State; updateFn: (prevState: State, input: InferIn<S>) => State }` `&` [`HookCallbacks`](/docs/types#hookcallbacks) | Object with required `currentState`, `updateFn` and optional callbacks. See below for more information. |

`utils` properties in detail:

| Name | Type | Purpose |
| ----------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `currentData` | `Data` (return type of the `safeActionFn` you passed as first argument) | An optimistic data setter. Usually this value comes from the parent Server Component. |
| `updateFn` | `(prevData: Data, input: InferIn<S>) => Data` | When you call the action via `execute`, this function determines how the optimistic data update is performed. Basically, here you define what happens **immediately** after `execute` is called, and before the actual result comes back from the server. |
| `currentState` | `State` (generic) | An optimistic state setter. This value should come from the parent Server Component. |
| `updateFn` | `(prevState: State, input: InferIn<S>) => State` | When you call the action via `execute`, this function determines how the optimistic state update is performed. Basically, here you define what happens **immediately** after `execute` is called, and before the actual result comes back from the server (after revalidation). |
| `{ onExecute?, onSuccess?, onError?, onSettled? }` | [`HookCallbacks`](/docs/types#hookcallbacks) | Optional callbacks. More information about them [here](/docs/execution/hooks/callbacks). |

### `useOptimisticAction` return object
Expand All @@ -127,7 +135,7 @@ export default function AddLikes({ likesCount }: Props) {
| `execute` | `(input: InferIn<S>) => void` | An action caller with no return. The input is the same as the safe action you passed to the hook. |
| `input` | `InferIn<S> \| undefined` | The input passed to the `execute` function. |
| `result` | [`HookResult`](/docs/types#hookresult) | When the action gets called via `execute`, this is the result object. |
| `optimisticData` | `Data` (return type of the `safeActionFn` you passed as first argument) | This is the data that gets updated immediately after `execute` is called, with the behavior you defined in the `reducer` function hook argument. The initial state is what you provided to the hook via `initialOptimisticData` argument. |
| `optimisticState` | `State` | This contains the state that gets updated immediately after `execute` is called, with the behavior you defined in the `updateFn` function. The initial state is what you provided to the hook via `currentState` argument. If an error occurs during action execution, the `optimisticState` reverts to the state that it had pre-last-last action execution. |
| `reset` | `() => void` | Programmatically reset `input` and `result` object with this function. |
| `status` | [`HookActionStatus`](/docs/types#hookresult) | The action current status. |
| `isIdle` | `boolean` | True if the action status is `idle`. |
Expand Down

0 comments on commit c38dbe1

Please sign in to comment.