C.W.K.
Stream
Lesson 05 of 06 · published

Composing Actions — Chains, Wrappers, Hooks

~11 min · actions, composition, patterns

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Once you have one good Action, you'll want to wrap it. Log every action. Add a confirmation step. Run a follow-up on success. Three patterns cover most of it.

Pattern 1 — wrap an action for cross-cutting concerns

You want every save action to log to your analytics service. Write a higher-order action that wraps any action and adds the side effect:

function withLogging<S>(name: string, action: (s: S, fd: FormData) => Promise<S>) {
  return async (s: S, fd: FormData): Promise<S> => {
    logEvent(`action.start.${name}`);
    try {
      const result = await action(s, fd);
      logEvent(`action.success.${name}`);
      return result;
    } catch (e) {
      logEvent(`action.failure.${name}`);
      throw e;
    }
  };
}

const loggedSaveDraft = withLogging('saveDraft', saveDraft);

Pattern 2 — chain actions in a single submit

Sometimes one submit triggers two actions (save draft, then send email). Compose inside your action function:

async function saveAndNotify(_prev, fd) {
  const draft = await saveDraft(fd);
  await sendNotification(draft.id);
  return draft;
}

This stays one Action from React's perspective — the pending state spans the whole chain.

Pattern 3 — extract reusable action logic into a hook

If the same action wiring (validation, logging, optimistic update) repeats across components, wrap it in a custom hook. The hook exposes the action and any related state.

Actions are functions; functions compose. No special framework required. If you'd refactor a regular async function for clarity, you'd refactor an Action the same way. Logging, retries, rate limiting, optimistic-update setup — all just function composition.

Code

A reusable useFormAction hook·tsx
import { useActionState, useOptimistic } from "react";

type ActionFn<S> = (state: S, formData: FormData) => Promise<S>;

export function useFormAction<S>(
  action: ActionFn<S>,
  initial: S,
  optimisticUpdater?: (current: S, fd: FormData) => S
) {
  const [state, formAction, isPending] = useActionState(action, initial);
  const [optimistic, addOptimistic] = useOptimistic(
    state,
    optimisticUpdater ?? ((s) => s)
  );

  const wrappedAction = async (fd: FormData) => {
    if (optimisticUpdater) addOptimistic(fd as unknown as Parameters<typeof addOptimistic>[0]);
    await formAction(fd);
  };

  return { state: optimistic, action: wrappedAction, isPending };
}

// Caller — declare action + initial + optional optimistic updater, get a tidy bundle.
// const { state, action, isPending } = useFormAction(saveTitle, { title: '' }, ...);

External links

Exercise

Take your subscribe-form action. Wrap it with withLogging (Pattern 1) and verify each submit prints start + success/failure to console. Then extract the action-state wiring into a useSubscribeForm() hook used inside the component. Notice how the component becomes pure rendering once the hook owns the action lifecycle.
Hint
withLogging's signature should mirror the action: (prevState, formData) => Promise<newState>. The wrapper preserves the contract; the hook abstracts the assembly.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.