Better Next Actions
A better way to manage your next actions
This is a forked version of the next-safe-action package.
See original source here: https://github.com/TheEdoRan/next-safe-action
The original package is licensed under the MIT license.
Current supported version: 5.2.1
Installation
Create lib/actions/hook.ts
"use client";
/**
* This is a forked version of the next-safe-action package.
* See original source here: https://github.com/TheEdoRan/next-safe-action
* The original package is licensed under the MIT license.
*/
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { isNotFoundError } from "next/dist/client/components/not-found";
import { isRedirectError } from "next/dist/client/components/redirect";
import type { z } from "zod";
import type {
HookActionStatus,
HookCallbacks,
HookResult,
SafeAction,
} from "@/lib/actions";
import { isError } from "@/lib/actions/utils";
const DEFAULT_RESULT: HookResult<z.ZodTypeAny, any, []> = {
data: undefined,
serverError: undefined,
validationError: undefined,
fetchError: undefined,
actionError: undefined,
};
const getActionStatus = <
const Schema extends z.ZodTypeAny,
const Data,
const BetterActionErrors extends readonly string[],
>(
isExecuting: boolean,
result: HookResult<Schema, Data, BetterActionErrors>,
): HookActionStatus => {
if (isExecuting) {
return "executing";
} else if (typeof result.data !== "undefined") {
return "hasSucceeded";
} else if (
typeof result.validationError !== "undefined" ||
typeof result.serverError !== "undefined" ||
typeof result.fetchError !== "undefined" ||
typeof result.actionError !== "undefined"
) {
return "hasErrored";
}
return "idle";
};
const useActionCallbacks = <
const Schema extends z.ZodTypeAny,
const Data,
const BetterActionErrors extends readonly string[],
>(
result: HookResult<Schema, Data, BetterActionErrors>,
input: z.input<Schema>,
status: HookActionStatus,
reset: () => void,
cb?: HookCallbacks<Schema, Data, BetterActionErrors>,
) => {
const onExecuteRef = useRef(cb?.onExecute);
const onSuccessRef = useRef(cb?.onSuccess);
const onErrorRef = useRef(cb?.onError);
const onSettledRef = useRef(cb?.onSettled);
// Execute the callback when the action status changes.
useEffect(() => {
const onExecute = onExecuteRef.current;
const onSuccess = onSuccessRef.current;
const onError = onErrorRef.current;
const onSettled = onSettledRef.current;
const executeCallbacks = async () => {
switch (status) {
case "executing":
await Promise.resolve(onExecute?.(input));
break;
case "hasSucceeded":
await Promise.resolve(onSuccess?.(result.data!, input, reset));
await Promise.resolve(onSettled?.(result, input, reset));
break;
case "hasErrored":
await Promise.resolve(onError?.(result, input, reset));
await Promise.resolve(onSettled?.(result, input, reset));
break;
}
};
executeCallbacks().catch(console.error);
}, [status, result, reset, input]);
};
export const useBetterAction = <
const Schema extends z.ZodTypeAny,
const Data,
const BetterActionErrors extends readonly string[],
>(
safeAction: SafeAction<Schema, Data, BetterActionErrors>,
callbacks?: HookCallbacks<Schema, Data, BetterActionErrors>,
) => {
const [, startTransition] = useTransition();
const [result, setResult] =
useState<HookResult<Schema, Data, BetterActionErrors>>(DEFAULT_RESULT);
const [input, setInput] = useState<z.input<Schema>>();
const [isExecuting, setIsExecuting] = useState(false);
const status = getActionStatus<Schema, Data, BetterActionErrors>(
isExecuting,
result,
);
const execute = useCallback(
(input: z.input<Schema>) => {
setInput(input);
setIsExecuting(true);
return startTransition(() => {
return safeAction(input)
.then((res) => setResult(res ?? DEFAULT_RESULT))
.catch((e) => {
if (isRedirectError(e) || isNotFoundError(e)) {
throw e;
}
setResult({
fetchError: isError(e) ? e.message : "Something went wrong",
});
})
.finally(() => {
setIsExecuting(false);
});
});
},
[safeAction],
);
const executeAsync = useCallback(
async (
input: z.input<Schema>,
): Promise<
| {
ok: true;
data: Data;
input: Schema;
}
| {
ok: false;
actionError: BetterActionErrors[number] | undefined;
serverError: string | undefined;
}
> => {
setInput(input);
setIsExecuting(true);
try {
const res = await safeAction(input);
if (res.data !== undefined) {
setResult(res);
setIsExecuting(false);
return {
ok: true,
data: res.data,
input,
};
} else {
setResult(res);
setIsExecuting(false);
return {
ok: false,
actionError: res.actionError,
serverError: res.serverError,
};
}
} catch (e) {
if (isRedirectError(e) || isNotFoundError(e)) {
throw e;
}
setResult({
fetchError: isError(e) ? e.message : "Something went wrong",
});
setIsExecuting(false);
return {
ok: false,
actionError: undefined,
serverError: isError(e) ? e.message : "Something went wrong",
};
}
},
[safeAction],
);
const reset = useCallback(() => {
setResult(DEFAULT_RESULT);
}, []);
useActionCallbacks(result, input, status, reset, callbacks);
return {
execute,
executeAsync,
result,
reset,
status,
hasSucceeded: status === "hasSucceeded",
hasErrored: status === "hasErrored",
hasSettled: status === "hasErrored" || status === "hasSucceeded",
isExecuting,
isIdle: status === "idle",
};
};
Create lib/actions/index.ts
/**
* This is a forked version of the next-safe-action package.
* See original source here: https://github.com/TheEdoRan/next-safe-action
* The original package is licensed under the MIT license.
*/
import { isNotFoundError } from "next/dist/client/components/not-found";
import { isRedirectError } from "next/dist/client/components/redirect";
import type { z } from "zod";
import {
BetterActionError,
DEFAULT_SERVER_ERROR,
isError,
} from "@/lib/actions/utils";
/* -------------------------------------------------------------------------- */
/* TYPES */
/* -------------------------------------------------------------------------- */
/**
* Type of the function called from Client Components with typesafe input data.
*/
export type SafeAction<
Schema extends z.ZodTypeAny,
Data,
BetterActionErrors extends readonly string[],
> = (input: z.input<Schema>) => Promise<{
data?: Data;
serverError?: string;
validationError?: Partial<Record<keyof z.input<Schema> | "_root", string[]>>;
actionError?: BetterActionErrors[number];
}>;
/**
* Type of the function that executes server code when defining a new safe action.
*/
export type ServerCode<
Schema extends z.ZodTypeAny,
Data,
Context,
BetterActionErrors extends readonly string[],
> = (options: {
input: z.infer<Schema>;
ctx: Context;
error: (e: BetterActionErrors[number]) => void;
}) => Promise<Data>;
// HOOKS
/**
* Type of `result` object returned by `useAction` and `useOptimisticAction` hooks.
*/
export type HookResult<
Schema extends z.ZodTypeAny,
Data,
BetterActionErrors extends readonly string[],
> = Awaited<ReturnType<SafeAction<Schema, Data, BetterActionErrors>>> & {
fetchError?: string;
};
/**
* Type of hooks callbacks. These are executed when action is in a specific state.
*/
export interface HookCallbacks<
Schema extends z.ZodTypeAny,
Data,
BetterActionErrors extends readonly string[],
> {
onExecute?: (input: z.input<Schema>) => MaybePromise<void>;
onSuccess?: (
data: Data,
input: z.input<Schema>,
reset: () => void,
) => MaybePromise<void>;
onError?: (
error: Omit<HookResult<Schema, Data, BetterActionErrors>, "data">,
input: z.input<Schema>,
reset: () => void,
) => MaybePromise<void>;
onSettled?: (
result: HookResult<Schema, Data, BetterActionErrors>,
input: z.input<Schema>,
reset: () => void,
) => MaybePromise<void>;
}
/**
* Type of the action status returned by `useAction` and `useOptimisticAction` hooks.
*/
export type HookActionStatus =
| "idle"
| "executing"
| "hasSucceeded"
| "hasErrored";
export type MaybePromise<T> = Promise<T> | T;
/* -------------------------------------------------------------------------- */
/* HOOKS */
/* -------------------------------------------------------------------------- */
export const createBetterActionClient = <
Context,
MoreOptions extends Record<string, unknown> = Record<string, never>,
>(createOpts?: {
handleServerErrorLog?: (e: Error) => MaybePromise<void>;
handleReturnedServerError?: (
e: Error,
) => MaybePromise<{ serverError: string }>;
middleware?: (opts: MoreOptions) => MaybePromise<Context>;
}) => {
// If server log function is not provided, default to `console.error` for logging
// server error messages.
const handleServerErrorLog =
createOpts?.handleServerErrorLog ||
((e) => {
console.error("Action error:", e.message);
});
// If `handleReturnedServerError` is provided, use it to handle server error
// messages returned on the client.
// Otherwise mask the error and use a generic message.
const handleReturnedServerError =
createOpts?.handleReturnedServerError ||
(() => ({ serverError: DEFAULT_SERVER_ERROR }));
// `actionBuilder` is the server function that creates a new action.
// It expects an input schema and a `serverCode` function, so the action
// knows what to do on the server when called by the client.
// It returns a function callable by the client.
const actionBuilder = <
const Schema extends z.ZodTypeAny,
const Data,
const BetterActionErrors extends readonly string[],
>(
options: {
input: Schema;
errors?: BetterActionErrors;
} & MoreOptions,
serverCode: ServerCode<Schema, Data, Context, BetterActionErrors>,
): SafeAction<Schema, Data, BetterActionErrors> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { input: schema, errors, ...moreOptions } = options;
// This is the function called by client. If `input` fails the schema
// parsing, the function will return a `validationError` object, containing
// all the invalid fields provided.
return async (clientInput) => {
try {
const parsedInput = await schema.safeParseAsync(clientInput);
if (!parsedInput.success) {
const { formErrors, fieldErrors } = parsedInput.error.flatten();
return {
validationError: {
_root: formErrors.length ? formErrors : undefined,
...fieldErrors,
} as Partial<Record<keyof z.input<Schema> | "_root", string[]>>,
};
}
// Get the context if `middleware` is provided.
const ctx = (await Promise.resolve(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Arbitrary type cast error -> still works
createOpts?.middleware?.(moreOptions),
)) as Context;
// Get `result.data` from the server code function. If it doesn't return
// anything, `data` will be `null`.
const data = ((await serverCode({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
input: parsedInput.data,
ctx,
error: (e: BetterActionErrors[number]) => {
throw new BetterActionError(e);
},
})) ?? null) as Data;
return { data };
} catch (e: unknown) {
// next/navigation functions work by throwing an error that will be
// processed internally by Next.js. So, in this case we need to rethrow it.
if (isRedirectError(e) || isNotFoundError(e)) {
throw e;
}
if (e instanceof BetterActionError) {
const reason = e.getReason() as BetterActionErrors[number];
return {
serverError: undefined,
actionError: reason,
};
}
// If error cannot be handled, warn the user and return a generic message.
if (!isError(e)) {
console.warn(
"Could not handle server error. Not an instance of Error: ",
e,
);
return { serverError: DEFAULT_SERVER_ERROR };
}
await Promise.resolve(handleServerErrorLog(e));
return await Promise.resolve(handleReturnedServerError(e));
}
};
};
return <
Schema extends z.ZodTypeAny,
const BetterActionErrors extends readonly string[],
>(
input: Schema,
options?: { errors?: BetterActionErrors } & MoreOptions,
) => {
return <const Data>(
serverCode: ServerCode<Schema, Data, Context, BetterActionErrors>,
) => {
return actionBuilder(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Arbitrary type cast error -> still works
{
input: input,
...options,
},
serverCode,
);
};
};
};
Create lib/actions/utils.ts
export const DEFAULT_SERVER_ERROR =
"Something went wrong while executing the operation";
export const isError = (error: any): error is Error => error instanceof Error;
export class BetterActionError<Errors extends readonly string[]> extends Error {
private reason: Errors[number];
constructor(message: Errors[number]) {
super(message);
this.reason = message;
}
getReason() {
return this.reason;
}
}
Create action client in lib/actions/action.ts
import { createBetterActionClient } from "@/lib/actions";
import { getSession } from "@/server/auth/server";
export const action = createBetterActionClient({
async middleware(opts: { public?: boolean }) {
const session = await getSession();
if (!opts?.public && !session?.user) {
throw new Error("Unauthorized");
}
return {
session,
};
},
});
What changed?
Aliases for status-checking
For convinience I added boolean values to the client hook that can be used instead of checking against the status
variable.
// action as a client hook
const myAction = useBetterAction(action);
// aliases
myAction.isIdle; // equals myAction.status === "idle"
myAction.isExecuting; // equals myAction.status === "executing"
myAction.hasSucceeded; // equals myAction.status === "hasSucceeded"
myAction.hasErrored; // equals myAction.status === "hasErrored"
Curry the action handler
In the original package the created client takes two arguments: the schema and the implementation. To establish the new features i wanted to introduce, the client now is a function that takes the schema
as first argument and an optional options
object as the second one. It then returns another function that takes the implementation as argument. In the implementation callback, the input and context (and other new things) aren't seperate arguments anymore, but one object.
// before
const action = createAction(
z.object({ id: z.string() }),
async (input, ctx) => {
// ... implementation
}
);
// after
const action = createAction(z.object({ id: z.string() }), {
// ... options
})(async ({ input, ctx }) => {
// ... implementation
});
Add optional custom errors that can be checked with typesafety
I always wanted to be able to throw some simple error messages that I can check for in the onError callback with automatic typesafety. One can now add those custom error messages in the added option argument in the action-client as an array. The implementation callback then exposes a function error
which you can use to throw a defined message. On the client you can then check for the error message via the first error
argument in the onError
callback.
// defining action
const addUserAction = createAction(z.object({ email: z.string().email() }), {
// define the error messages
errors: ["EMAIL_ALREADY_USED"],
})(async ({ ctx, input, error }) => {
// check if user with given email exists
if (user) {
error("EMAIL_ALREADY_USED"); // throws an error
}
});
// on the client
const addUser = useBetterAction(addUserAction, {
onError: (e) => {
if (e.actionError === "EMAIL_ALREADY_USED") {
// ... handle error
}
},
});
Add custom options for middleware.
!!! CAUTION: The implementation uses two @ts-ignores, so i don't know if there could potentially be a way to break this feature typewise but i will just ignore that for my own sanity !!!
I was missing the option to add custom information to an action (kinda like the meta
field in tRPC), which then could be used in the middleware. That's why i added the possibility to define them directly in the middleware
function.
// example: add custom field to check if the user is authenticated every time the action gets called
const createAction = createBetterActionClient({
// just explicitly type the first argument of the middleware function
async middleware(opts?: { secured?: boolean }) {
const session = await getSession();
if (opts?.secured && !session?.user) {
// when the secured flag is set and the user is not logged in: throw error
throw new Error("Unauthorized");
}
// return the context in the known way
return {
//...
};
},
});
const addItemAction = createAction(z.object({ name: z.string() }), {
// ... now all options defined in the middleware function are exposed in this object
secured: true,
})(async ({ ctx, input }) => {
// ...
});
Add executeAsync
In some cases i wanted to wait for the result of the executing hook but that was not possible in the original package. The function will only throw if an unknown error occurs. In every other case the function will return an object with a key "ok" which is either true, which reveals the returned data or false, which reveals the possible errors. I decided to do that because you can still check for the custom custom action errors in a typesafe way. If an error would be thrown, one couldn't directly infer the error from it, without any additional functions.
const myAction = useBetterAction(action);
async function execute() {
const res = await myAction.executeAsync();
}
No useOptimisticAction
I currently don't use it and was to lazy to get busy with implementing it with the new features.