Two hooks that fixed how I handle server state in React

A stable callback ref and a three-way state fallback. Two small custom hooks I extracted from a typing trainer app to stop fighting React's render cycle.

Two hooks that fixed how I handle server state in React

Working on a typing trainer app, I ran into two problems that kept showing up in different parts of the codebase. I solved them with small custom hooks. Both removed real classes of bugs and made my components much easier to read.

The first problem: stale callbacks in stable handlers

I had a pattern like this all over the place:

const updateProfile = useUpdateUserProfile();

const handleChange = useCallback((value: number) => {
  updateProfile.mutate({ wpmGoal: value });
}, [updateProfile]);

useUpdateUserProfile is a thin wrapper around TanStack Query's useMutation:

export function useUpdateUserProfile() {
  const { user } = useAuth();
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: Partial<TUserProfile>) => {
      if (!user) return;
      return updateUserProfile(user.uid, data);
    },
    onSettled: async () => {
      await queryClient.invalidateQueries({ queryKey: ["userProfile"] });
    },
  });
}

useMutation returns a new object on every render, so updateProfile itself changes reference constantly, and updateProfile.mutate along with it. The problem is that updateProfile changes reference on every render, which forces handleChange to be recreated on every render, which then ripples into child components re-rendering unnecessarily. The usual fix is useCallback with a dependency array, but adding updateProfile to the deps defeats the purpose.

You don't need the callback to be reactive. You just need it to always call the latest version of the function. That's what a ref is for.

export function useRefState<T>(value: T): RefObject<T> {
  const ref = useRef(value);

  useEffect(() => {
    ref.current = value;
  });

  return ref;
}

No dependency array on the useEffect. It runs after every render and keeps ref.current in sync with the latest value. The ref itself is stable across renders.

Now you can write:

const updateProfile = useUpdateUserProfile();
const updateProfileRef = useRefState(updateProfile.mutate);

const handleChange = useCallback((value: number) => {
  updateProfileRef.current({ wpmGoal: value });
}, [updateProfileRef]); // stable — ref identity never changes

The callback is now stable, and it always calls the latest mutate function. No stale closures, no unnecessary re-renders.

The second problem: inputs backed by server data

Preferences sliders were the concrete trigger here. A slider needs to show the user's saved value when the page loads, but also track changes instantly as the user drags, and debounce the saves so you're not hammering the server on every pixel.

The data-fetching and mutation hooks are both thin wrappers around TanStack Query. useUserProfile fetches the saved profile:

export function useUserProfile() {
  const { user } = useAuth();

  return useQuery({
    enabled: !!user,
    queryKey: ["userProfile"],
    queryFn: () => {
      if (!user) return null;
      return getUserProfile(user.uid);
    },
  });
}

useQuery returns data: undefined until the fetch resolves, which is why the fallback chain userProfile.data?.wpmGoal ?? DEFAULT_WPM_GOAL is necessary. The data simply isn't there yet on the first render.

The naive approach looks like this:

const userProfile = useUserProfile();
const updateProfile = useUpdateUserProfile();
const updateProfileRef = useRefState(updateProfile.mutate);

const [localWpmGoal, setLocalWpmGoal] = useState<number | null>(null);
const debouncedWpmGoal = useDebounce(localWpmGoal, 500);

const wpmGoal = localWpmGoal ?? userProfile.data?.wpmGoal ?? DEFAULT_WPM_GOAL;

useEffect(() => {
  if (debouncedWpmGoal === null) return;
  updateProfileRef.current({ wpmGoal: debouncedWpmGoal });
}, [debouncedWpmGoal, updateProfileRef]);

And then you pass wpmGoal to your slider. When server data hasn't loaded yet, the slider shows the default. When it loads, the slider shows the real value. When the user drags, localWpmGoal takes over and becomes the source of truth, and the debounced save fires 500ms after they stop.

The null initial state is load-bearing. It means "the user hasn't touched this yet." Without it, useState(DEFAULT_WPM_GOAL) would fire the save effect on mount with the default value, overwriting the user's actual data before the server response even arrives.

This works, but when you have three sliders, you have three copies of this 8-line block. When you have it in two different pages, you have six copies.

Extracting the hook

The pattern is mechanical enough to extract:

export function useServerState<T>(
  serverValue: T | undefined,
  defaultValue: T,
  onSave: (value: T) => void,
  delay = 500,
): [T, (value: T) => void] {
  const [localValue, setLocalValue] = useState<T | null>(null);
  const debouncedValue = useDebounce(localValue, delay);
  const onSaveRef = useRefState(onSave);

  useEffect(() => {
    if (debouncedValue === null) return;
    onSaveRef.current(debouncedValue);
  }, [debouncedValue, onSaveRef]);

  return [localValue ?? serverValue ?? defaultValue, (v: T) => setLocalValue(v)];
}

Note that useServerState uses useRefState internally to stabilize onSave, so callers don't have to memoize their callback.

The return type is [T, setter], not [T | null, setter]. The hook resolves the three-way local ?? server ?? default chain internally, so the caller just gets a ready-to-use value.

Usage collapses to:

const userProfile = useUserProfile();
const updateProfile = useUpdateUserProfile();

const [wpmGoal, setWpmGoal] = useServerState(
  userProfile.data?.wpmGoal,
  DEFAULT_WPM_GOAL,
  (wpmGoal) => updateProfile.mutate({ wpmGoal }),
);

const [accuracyGoal, setAccuracyGoal] = useServerState(
  userProfile.data?.accuracyGoal,
  DEFAULT_ACCURACY_GOAL,
  (accuracyGoal) => updateProfile.mutate({ accuracyGoal }),
);

// render
<Range value={wpmGoal} onChange={(e) => setWpmGoal(Number(e.target.value))} />
<Range value={accuracyGoal} onChange={(e) => setAccuracyGoal(Number(e.target.value))} />

No useEffect, no useDebounce, no useRefState, no null checks. Just data in, setter out.

When to reach for these

useRefState is useful any time you need a stable function reference that reads the latest version of a value. Common cases: event handlers inside useCallback that close over frequently-changing state or props, effects with long lifetimes that need to call an up-to-date function without re-registering.

useServerState is useful when an input is backed by server data you want to show immediately on load, but the user's local changes should take priority and be saved back with a debounce. Settings forms, preference sliders, editable profile fields.

The two hooks compose naturally. useServerState uses useRefState under the hood to manage the save callback. You rarely need to think about that, but it means you never have to worry about passing a stale onSave to useServerState.

Neither hook is more than fifteen lines. Both were extracted directly from patterns that were already in the codebase, not designed up front. They were noticed and named once they appeared in enough places to be worth abstracting. That's usually the right time.