Skip to content

React – Hooks

Hooks are how React components manage state, side effects, and shared logic. This page covers every hook used at Cygnus Dynamics — when to use each, common mistakes, and the patterns that keep hook code clean and testable.

Rules of Hooks

These are enforced automatically by eslint-plugin-react-hooks.

  • Call hooks at the top level — never inside loops, conditions, or nested functions
  • Call hooks only from React function components or other custom hooks — never from plain JavaScript functions
  • Custom hooks must start with use — this is how React identifies them as hooks
// ❌ Incorrect — hook called inside a condition
const OrderCard = ({ order }: OrderCardProps) => {
  if (!order) return null              // early return before hook call
  const [isExpanded, setIsExpanded] = useState(false)   // ← violates Rules of Hooks
}

// ✅ Correct — all hooks called before any early return
const OrderCard = ({ order }: OrderCardProps) => {
  const [isExpanded, setIsExpanded] = useState(false)   // ← hooks first
  if (!order) return null
  return <div>{order.reference}</div>
}

useState

Use useState for local UI state — state that belongs to a single component and doesn't need to be shared.

Prefer many small over one large

// ✅ Correct — easy to update independently
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<Order[]>([])

// ❌ Incorrect — updating one field requires spreading the rest
const [state, setState] = useState({ isLoading: false, error: null, data: [] })
setState(prev => ({ ...prev, isLoading: true }))  // verbose and error-prone

Functional updates for derived state

When the new state depends on the previous state, always use the functional form to avoid stale closures.

// ✅ Correct — functional update is always based on the latest state
const [count, setCount] = useState(0)
setCount(prev => prev + 1)

// ❌ Incorrect — `count` may be stale inside async callbacks
setCount(count + 1)

// ✅ Correct — toggling with functional update
const [isOpen, setIsOpen] = useState(false)
setIsOpen(prev => !prev)

Lazy initialisation for expensive initial state

// ✅ Correct — function is called only once on mount
const [filters, setFilters] = useState(() => {
  const saved = localStorage.getItem('orderFilters')
  return saved ? JSON.parse(saved) : defaultFilters
})

// ❌ Incorrect — localStorage.getItem runs on every render
const [filters, setFilters] = useState(
  JSON.parse(localStorage.getItem('orderFilters') ?? 'null') ?? defaultFilters
)

useReducer

Use useReducer when state has multiple related fields that update together, or when the next state depends on the previous in complex ways.

// ✅ useReducer for a multi-field form with validation state
interface FormState {
  email: string
  password: string
  isSubmitting: boolean
  errors: Record<string, string>
}

type FormAction =
  | { type: 'SET_FIELD'; field: 'email' | 'password'; value: string }
  | { type: 'SET_ERRORS'; errors: Record<string, string> }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_SUCCESS' }
  | { type: 'SUBMIT_FAILURE'; error: string }

const formReducer = (state: FormState, action: FormAction): FormState => {
  switch (action.type) {
    case 'SET_FIELD':
      return { ...state, [action.field]: action.value, errors: {} }
    case 'SET_ERRORS':
      return { ...state, errors: action.errors }
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true, errors: {} }
    case 'SUBMIT_SUCCESS':
      return { ...state, isSubmitting: false }
    case 'SUBMIT_FAILURE':
      return { ...state, isSubmitting: false, errors: { form: action.error } }
    default:
      return state
  }
}

const LoginForm = () => {
  const [state, dispatch] = useReducer(formReducer, {
    email: '',
    password: '',
    isSubmitting: false,
    errors: {},
  })

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    dispatch({ type: 'SUBMIT_START' })
    try {
      await authService.login(state.email, state.password)
      dispatch({ type: 'SUBMIT_SUCCESS' })
    } catch {
      dispatch({ type: 'SUBMIT_FAILURE', error: 'Invalid credentials' })
    }
  }
}

useEffect

useEffect is for synchronising a component with an external system — DOM APIs, subscriptions, timers, and third-party libraries. It is not for data fetching (use TanStack Query) and not for data transformations (use useMemo).

Dependency arrays must be complete and accurate

// ✅ Correct — all values used inside the effect are in the deps array
useEffect(() => {
  document.title = `Order #${orderId} — ${status}`
}, [orderId, status])     // both values in deps

// ❌ Incorrect — missing dependency causes stale values
useEffect(() => {
  document.title = `Order #${orderId} — ${status}`
}, [orderId])   // status is missing — title won't update when status changes

Cleanup to prevent memory leaks

Every effect that sets up a subscription, timer, or event listener must return a cleanup function.

// ✅ Correct — subscription cleaned up on unmount or when userId changes
useEffect(() => {
  const unsubscribe = orderStatusService.subscribe(userId, (status) => {
    setOrderStatus(status)
  })

  return () => unsubscribe()   // ← cleanup
}, [userId])

// ✅ Correct — timer cleaned up
useEffect(() => {
  const timer = setInterval(() => {
    setSecondsRemaining(prev => prev - 1)
  }, 1000)

  return () => clearInterval(timer)   // ← cleanup
}, [])

// ❌ Incorrect — no cleanup — subscription keeps firing after component unmounts
useEffect(() => {
  orderStatusService.subscribe(userId, setOrderStatus)
}, [userId])

Avoid useEffect for derived values

If a value can be calculated from existing state or props synchronously, calculate it during render with useMemo — do not derive it in a useEffect.

// ❌ Incorrect — useEffect + setState for something that can be computed
const [total, setTotal] = useState(0)
useEffect(() => {
  setTotal(items.reduce((sum, item) => sum + item.price * item.quantity, 0))
}, [items])   // causes an extra render on every items change

// ✅ Correct — compute during render, no extra state, no extra render
const total = useMemo(
  () => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
  [items]
)

Avoid useEffect for event-driven logic

// ❌ Incorrect — useEffect watching state to trigger a side effect
useEffect(() => {
  if (isSubmitted) {
    submitOrder(formData)
  }
}, [isSubmitted, formData])

// ✅ Correct — call the side effect directly in the event handler
const handleSubmit = async () => {
  await submitOrder(formData)
  setIsSubmitted(true)
}

useRef

Use useRef for two distinct purposes: accessing DOM elements, and storing mutable values that should not trigger re-renders.

// ✅ DOM access — focus management
const inputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
  inputRef.current?.focus()
}, [])

return <input ref={inputRef} type="text" />

// ✅ Mutable value — storing previous value without triggering re-render
const previousOrderId = useRef<string | null>(null)

useEffect(() => {
  if (previousOrderId.current !== orderId) {
    trackOrderChange(previousOrderId.current, orderId)
    previousOrderId.current = orderId
  }
}, [orderId])

// ✅ Mutable value — storing interval ID
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)

const startPolling = () => {
  intervalRef.current = setInterval(fetchStatus, 5000)
}

const stopPolling = () => {
  if (intervalRef.current) clearInterval(intervalRef.current)
}

useContext

Use useContext for state that is read by many components at different levels of the tree — theme, locale, authenticated user. Do not use it for frequently-updated state (causes re-renders across all consumers).

// ✅ Correct — typed context with a custom hook
interface AuthContextValue {
  user: User | null
  isAuthenticated: boolean
  logout: () => void
}

const AuthContext = createContext<AuthContextValue | null>(null)

// Always export a hook — never export the context directly
export const useAuth = (): AuthContextValue => {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used inside AuthProvider')
  }
  return context
}

export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [user, setUser] = useState<User | null>(null)

  const logout = () => {
    authService.logout()
    setUser(null)
  }

  return (
    <AuthContext.Provider value={{ user, isAuthenticated: !!user, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

// Usage
const ProfileMenu = () => {
  const { user, logout } = useAuth()
  return (
    <div>
      <span>{user?.name}</span>
      <button onClick={logout}>Sign out</button>
    </div>
  )
}

useMemo and useCallback

Measure before adding — React 19 compiler note

On projects using the React Compiler (React 19+), manual useMemo and useCallback are often unnecessary — the compiler memoises automatically. Before adding either hook, check whether your project has the compiler enabled. If it does, profile first and add manual memoisation only when you can show a measurable performance benefit.

Use useMemo for expensive computations that should not re-run on every render. Use useCallback for callbacks passed as props to memoised child components.

// ✅ useMemo — expensive computation
const sortedOrders = useMemo(
  () => [...orders].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
  [orders]
)

// ✅ useCallback — stable reference for a memoised child
const handleDelete = useCallback(
  (orderId: string) => {
    dispatch({ type: 'DELETE_ORDER', orderId })
  },
  [dispatch]
)

// ❌ useMemo on a cheap operation — adds overhead without benefit
const title = useMemo(() => `Hello, ${user.name}`, [user.name])
// Just do: const title = `Hello, ${user.name}`

// ❌ Stale closure — missing dispatch in deps
const handleDelete = useCallback(
  (orderId: string) => {
    dispatch({ type: 'DELETE_ORDER', orderId })
  },
  []   // dispatch missing — stale reference
)

Custom Hooks

Extract stateful logic and side effects into custom hooks when the same logic is used in more than one component, or when a component becomes difficult to read because it mixes UI and logic.

Custom hook rules

  • Name must start with use
  • Must be a pure function — no JSX, no rendering
  • Should return a stable API (typed return object or tuple)
  • Should be testable in isolation with renderHook
// ✅ Custom hook — data fetching with loading and error state
interface UseOrderResult {
  order: Order | null
  isLoading: boolean
  error: Error | null
  refetch: () => void
}

const useOrder = (orderId: string): UseOrderResult => {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['order', orderId],
    queryFn: () => orderApi.getOrder(orderId),
    enabled: !!orderId,
  })

  return {
    order: data ?? null,
    isLoading,
    error: error as Error | null,
    refetch,
  }
}

// ✅ Custom hook — form field with validation
interface UseFieldResult {
  value: string
  error: string | null
  onChange: (value: string) => void
  onBlur: () => void
  reset: () => void
}

const useField = (
  initialValue: string,
  validate: (value: string) => string | null
): UseFieldResult => {
  const [value, setValue] = useState(initialValue)
  const [error, setError] = useState<string | null>(null)
  const [isTouched, setIsTouched] = useState(false)

  const onChange = (newValue: string) => {
    setValue(newValue)
    if (isTouched) setError(validate(newValue))
  }

  const onBlur = () => {
    setIsTouched(true)
    setError(validate(value))
  }

  const reset = () => {
    setValue(initialValue)
    setError(null)
    setIsTouched(false)
  }

  return { value, error, onChange, onBlur, reset }
}

// ✅ Custom hook — media query
const useMediaQuery = (query: string): boolean => {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches)

  useEffect(() => {
    const media = window.matchMedia(query)
    const listener = (e: MediaQueryListEvent) => setMatches(e.matches)
    media.addEventListener('change', listener)
    return () => media.removeEventListener('change', listener)
  }, [query])

  return matches
}

// Usage
const isMobile = useMediaQuery('(max-width: 768px)')

Data Fetching — TanStack Query

Do not use useEffect + useState for data fetching. Use TanStack Query (formerly React Query). It handles loading states, caching, background refetching, error states, and deduplication out of the box.

// ✅ Correct — TanStack Query for all server state
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

const OrderList = ({ userId }: { userId: string }) => {
  const { data: orders, isLoading, error } = useQuery({
    queryKey: ['orders', userId],
    queryFn: () => orderApi.getOrders(userId),
    staleTime: 5 * 60 * 1000,   // data is fresh for 5 minutes
  })

  if (isLoading) return <OrderListSkeleton />
  if (error) return <ErrorMessage error={error} />

  return (
    <ul>
      {orders?.map(order => (
        <OrderListItem key={order.id} order={order} />
      ))}
    </ul>
  )
}

const CancelOrderButton = ({ orderId }: { orderId: string }) => {
  const queryClient = useQueryClient()

  const { mutate: cancelOrder, isPending } = useMutation({
    mutationFn: () => orderApi.cancelOrder(orderId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] })
    },
    onError: (error) => {
      toast.error(`Failed to cancel order: ${error.message}`)
    },
  })

  return (
    <button onClick={() => cancelOrder()} disabled={isPending}>
      {isPending ? 'Cancelling...' : 'Cancel order'}
    </button>
  )
}

// ❌ Incorrect — reinventing TanStack Query with useEffect
const OrderList = ({ userId }: { userId: string }) => {
  const [orders, setOrders] = useState([])
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    setIsLoading(true)
    orderApi.getOrders(userId)
      .then(setOrders)
      .catch(setError)
      .finally(() => setIsLoading(false))
  }, [userId])
  // no caching, no deduplication, no background refetch, no stale-while-revalidate
}