Custom Hooks

Extract stateful logic into a use-prefixed function: the rules of hooks, what a custom hook shares, and what it does not.

Syntaxfunction useThing(arg: Arg): Result { /* calls useState, useEffect, other hooks */ }

A custom hook is an ordinary function whose name starts with use and which calls other hooks. There is no special API and nothing to register. It exists so two components can share stateful logic — a debounce, a timer, a subscription, a permission check — without sharing a parent and without copying the code.

How it works

Move the useState and useEffect calls out of the component and into the function. Take what it needs as arguments and return what the caller needs. Returning an object is usual, so callers destructure by name; returning a tuple with as const reads like useState. Both are the same mechanism.

The rules of hooks

Hooks may only be called at the top level of a component or of another hook. Never inside a condition, a loop, or after an early return. React matches each hook call to its stored state by call order, so an order that differs between renders breaks. The use prefix is how the lint rule recognises a hook and enforces those rules. Rename it to getThing and the checks go quiet while the bug stays.

What trips people up

A custom hook shares logic, not state. Two components calling useDebouncedValue get two separate values and two separate timers. Calling the same hook twice does not connect anything. For one value that many components read, you need context or a store.

Example

Example · tsx
// A text box with two lines under it: the first updates as you type, the second
// only 400ms after you stop.
import { useEffect, useState } from "react";
import { StyleSheet, Text, TextInput, View } from "react-native";

// A custom hook: name starts with "use", body calls other hooks. Generic, so it
// debounces whatever type you give it.
function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    // Cleanup cancels the pending update when value changes before the timer fires.
    return () => clearTimeout(id);
  }, [value, delayMs]);

  return debounced;
}

export default function SearchScreen() {
  const [query, setQuery] = useState("");
  const settled = useDebouncedValue(query, 400);

  return (
    <View style={styles.screen}>
      <TextInput
        value={query}
        onChangeText={setQuery}
        placeholder="Search"
        autoCorrect={false}
        style={styles.input}
      />
      <Text>{`Typing: ${query}`}</Text>
      <Text>{`Settled: ${settled}`}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 24, gap: 12 },
  input: { borderWidth: 1, borderColor: "#cccccc", borderRadius: 8, padding: 12 },
});

When to use it

  • Debounce a search box so a request fires only after typing stops.
  • Wrap a permission check so several screens can read whether the camera is allowed.
  • Share form handling between a create screen and an edit screen that have no common parent.
  • Hide a subscription and its cleanup behind one call, so no component repeats the unsubscribe.

More examples

A conditional hook, and the fix

On screen: one line of text, either "Signed out" or the user's name. The branch has not disappeared; it has moved below the hooks and inside the effect. This is the shape to reach for whenever a screen renders nothing useful until some prop exists.

Example · tsx
import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";

type Props = { userId: string | null };

// WRONG, kept commented out so it cannot be pasted by accident. The hook runs only
// on some renders, so the hook order changes and React throws.
// function Broken({ userId }: Props) {
//   if (userId === null) return <Text>Signed out</Text>;
//   const [name, setName] = useState("");
//   return <Text>{name}</Text>;
// }

export default function Profile({ userId }: Props) {
  // RIGHT: every hook call first, unconditionally.
  const [name, setName] = useState("");

  useEffect(() => {
    if (userId === null) {
      setName("");
      return;
    }
    setName(`User ${userId}`); // the condition lives inside the effect
  }, [userId]);

  if (userId === null) {
    return <Text style={styles.row}>Signed out</Text>; // early return is fine below the hooks
  }

  return (
    <View style={styles.row}>
      <Text>{name}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  row: { padding: 16 },
});

Object return and tuple return

Two hooks that hold the same kind of state and differ only in what they hand back. Use the object form when there are three or more values and names help; use the tuple form for a value plus one updater, where a caller will want their own names. The comment at the bottom is the point most people miss: calling a hook twice makes two independent states.

Example · tsx
import { useCallback, useState } from "react";

// Object return: callers destructure by name, in any order.
export function useDisclosure(initial = false) {
  const [isOpen, setIsOpen] = useState(initial);
  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);
  // Property shorthand. Longhand, identical: { isOpen: isOpen, open: open, close: close }
  return { isOpen, open, close };
}

// Tuple return: callers rename freely, the way useState lets them. "as const" is what
// makes the type [boolean, () => void] instead of (boolean | (() => void))[].
export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  return [value, toggle] as const;
}

// Each call gets its own state: these two are unrelated pieces of state, not one shared flag.
// const [sheetOpen, toggleSheet] = useToggle();
// const [menuOpen, toggleMenu] = useToggle();

Composing a framework hook

On screen: a rounded banner whose background and text colour follow the device's light or dark setting, repainting when the setting changes. Wrapping useColorScheme in a hook of your own means the colour decisions live in one file, and every component asks for colours rather than for the scheme name.

Example · tsx
import { useMemo } from "react";
import { StyleSheet, Text, useColorScheme, View } from "react-native";

type Colors = { background: string; text: string };

// A custom hook can call framework hooks too. useColorScheme comes from react-native and
// returns "light", "dark", or null if the native Appearance module is not available.
function useColors(): Colors {
  const scheme = useColorScheme();
  return useMemo(
    () =>
      scheme === "dark"
        ? { background: "#111111", text: "#f5f5f5" }
        : { background: "#ffffff", text: "#111111" },
    [scheme]
  );
}

export default function Banner() {
  const colors = useColors();
  return (
    <View style={[styles.banner, { backgroundColor: colors.background }]}>
      <Text style={{ color: colors.text }}>System theme applied</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  banner: { padding: 16, borderRadius: 12 },
});

Discussion

  • Be the first to comment on this lesson.