cf.completefrontend
Open lab My progress
THE TYPESCRIPT FIELD GUIDE

Typing state & hooks

Type React state and hooks: useState with unions and null, useReducer with typed actions, useRef for elements, typed context, and your own custom hooks.

By the end, you can
  • 01
    Type stateKnow when useState works out the type, and when to write one, like useState<User | null>(null).
  • 02
    Type reducers and refsType a reducer's actions as a union, and create refs for elements with useRef<HTMLInputElement>(null).
  • 03
    Type context and hooksCreate typed context, remove null with a checking hook, and return pairs from custom hooks with as const.

State that keeps its type

A component often has to remember something between clicks: how many items are in the cart, what the shopper typed, whether a menu is open. React calls that state. You ask for it with hooks: functions whose names start with use, called at the top of a component. When state changes, React runs the component again and draws the new result. The previous lesson, Typing React components, covers props and events; this one covers what a component remembers.

A counter that remembers its countPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useState } from "react"; function CartCounter() {  const [count, setCount] = useState(0);//       ^?  return (    <p>      {count} items <button onClick={() => setCount(count + 1)}>Add tea</button>    </p>  );} createRoot(document.getElementById("root")!).render(<CartCounter />);

Line 2 imports the useState hook. Line 5 calls it with the starting value 0. It gives back a pair, which the square brackets take apart (Destructuring):

  • count, the current value,
  • setCount, a function that stores a new value and makes React draw the component again.

The query on line 6 shows const count: number. You never wrote number: TypeScript worked it out from the starting value 0, and from now on the state only holds numbers. Line 9 adds one on each click. Click Add tea twice and the page shows 2 items.

That type matters as soon as values come from outside. A text box always gives you a string, even a number box. Pick what line 9 passes to setQuantity:

Playground: what goes into the quantity state
import { createRoot } from "react-dom/client";import { useState } from "react"; function QuantityBox() {  const [quantity, setQuantity] = useState(1);  return (    <label>      Cakes: <input type="number" value={quantity}        onChange={(event) => setQuantity(event.target.value)} />      With the 2 in your cart: {quantity + 2}    </label>  );} createRoot(document.getElementById("root")!).render(<QuantityBox />);
setQuantity getsevent.target.value
What happens
TypeScript1 error: line 9
After typing 4With the 2 in your cart: 42
Try it yourself

A text box's value is always a string, and quantity holds a number. TypeScript says Argument of type 'string' is not assignable to parameter of type 'SetStateAction<number>'. Run it, type 4, and the page joins the text: 42.

TypeScript checks the code for real, and the page row is what React shows after you type 4 in the box. Each button changes only what line 9 passes to setQuantity. Reset goes back to event.target.value.

With event.target.value, TypeScript marks line 9 on purpose: Argument of type 'string' is not assignable to parameter of type 'SetStateAction<number>' means you're handing the setter text, and it only takes a number (SetStateAction is React's name for "a new value, or a function that makes one"). Run it anyway and type 4: line 10 joins "4" and 2 into 42. With Number(…) or valueAsNumber, it shows 6.

Left, two cards: the number 4, marked fits, and the text "4", marked error. Both have arrows to a card setQuantity(…). An arrow from setQuantity leads to a box named quantity, which holds a card saying number.what you pass4fits"4"errorsetQuantity(…)numberquantitythe state
useState(1) makes quantity a number, and setQuantity is the only way to change it, so it only takes a number. Text from a text box doesn't fit until you turn it into one.
Real-life analogyA parking spot for bikes only

A bike shop has a parking spot with a sign: bikes only. You never park there yourself; you hand your bike to the attendant, who parks it. If someone drives up in a car, the attendant waves them away before the car gets near the spot. Whenever you come back, you find a bike there, because that's all the spot ever takes.

In real life: A parking spot with a sign: bikes only
In TypeScript: useState(1): a state that holds a number
In real life: The attendant who parks for you
In TypeScript: The setter, setQuantity
In real life: The attendant waves away a car
In TypeScript: TypeScript marks setQuantity("4")
In real life: You come back and find a bike, as the sign said
In TypeScript: Every draw reads a number from quantity
The main idea

Each piece of state has a type, and its setter only takes that type. TypeScript works the type out from the starting value. When the starting value doesn't show everything the state will hold, like null or [], you say it yourself: useState<User | null>(null).

useState and the type it infers

useState is a generic function: its type parameter is the state's type. Usually TypeScript fills it in from the starting value. Sometimes the starting value is only a placeholder, like null for a user who hasn't signed in yet. Pick how line 9 creates the state:

Playground: say what the state can hold
A profile that signs in laterPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useState } from "react"; type User = { name: string; city: string }; const asha: User = { name: "Asha", city: "Pune" }; function Profile() {  const [user, setUser] = useState(null);//       ^?  return (    <p>      {user ? `Signed in as ${user.name}` : "Not signed in"}{" "}      <button onClick={() => setUser(asha)}>Sign in</button>    </p>  );} createRoot(document.getElementById("root")!).render(<Profile />);
user isnull
What happens
TypeScript2 errors: lines 13 and 14
When it runsNot signed in, then Signed in as Asha after a click
Try it yourself

From null alone, TypeScript decides user can only ever be null. Line 14 says Argument of type 'User' is not assignable to parameter of type 'SetStateAction<null>'. Line 13 can't read name either: a user that is always null is never there, so Property 'name' does not exist on type 'never'.

TypeScript checks the code for real. Each button changes only line 9, and the query on line 10 shows the type user gets. Reset goes back to useState(null).

useState(null) gives const user: null: the state can never hold anything else, so line 14 can't store Asha. useState<User | null>(null) says what's true: a User, or null for now (null & undefined). Then line 13 has to check for null before it reads user.name, which is exactly the check the page needs before anyone signs in.

An empty array has the same problem. It says nothing about what will go in it:

A wishlist that starts empty (an error on purpose)Pop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useState } from "react"; function Wishlist() {  const [items, setItems] = useState([]);//       ^?  return (    <p>      {items.length} saved <button onClick={() => setItems([...items, "Green tea"])}>Save tea</button>    </p>  );} createRoot(document.getElementById("root")!).render(<Wishlist />);

The query on line 6 shows never[]: an array that can hold nothing at all (never is the type with no values). So line 9 gets Type 'string' is not assignable to type 'never', on purpose: the text "Green tea" can't go in an array of nothing. Say what the array holds:

The wishlist with a type argumentPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useState } from "react"; function Wishlist() {  const [items, setItems] = useState<string[]>([]);//       ^?  return (    <p>      {items.length} saved <button onClick={() => setItems([...items, "Green tea"])}>Save tea</button>    </p>  );} createRoot(document.getElementById("root")!).render(<Wishlist />);

Line 5 now says useState<string[]>([]), the query shows const items: string[], and line 9 is fine. Click Save tea and the page shows 1 saved.

A type argument also keeps state to a few allowed words. Here the button's state is a union of literal types:

A save button with a typo in its state (an error on purpose)Pop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useState } from "react"; type SaveStatus = "idle" | "saving" | "saved"; function SaveButton() {  const [status, setStatus] = useState<SaveStatus>("idle");  return (    <button disabled={status === "saving"} onClick={() => setStatus("saveing")}>      {status === "saved" ? "Saved" : "Save address"}    </button>  );} createRoot(document.getElementById("root")!).render(<SaveButton />);

Line 4 lists the three states. Line 7 creates the state with that type. Line 9 sets "saveing", a typo, and TypeScript marks it on purpose: Argument of type '"saveing"' is not assignable to parameter of type 'SetStateAction<SaveStatus>'. Without <SaveStatus>, the state would be any string, the typo would pass, and line 9's check for "saving" would never be true.

The setter also takes a function that gets the current value and returns the next one:

A like button that counts from the current valuePop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useState } from "react"; function LikeButton() {  const [likes, setLikes] = useState(0);  return <button onClick={() => setLikes((current) => current + 1)}>{likes} likes</button>;//                                         ^?} createRoot(document.getElementById("root")!).render(<LikeButton />);

On line 6, setLikes gets a function. The query on line 7 shows current: number: TypeScript knows what the function gets, and checks that it returns a number. Click the button and it shows 1 likes. Read the table row by row: the starting value, the type TypeScript works out, and what to write when that isn't enough.

When to give useState a type
You writeThe state's typeWhen that isn't enough
useState(0)numberIt's enough: the start shows the type.
useState("")stringWhen only a few words are allowed: useState<SaveStatus>("idle").
useState(null)nullAlways, if something else comes later: useState<User | null>(null).
useState([])never[]Always: say what goes in, useState<string[]>([]).
useState(false)booleanIt's enough.

useReducer with typed actions

When state has several parts that change together, like a cart's items and its coupon, React offers useReducer. You write a reducer: a function that takes the current state and an action, an object that says what happened, and returns the next state (State management covers the idea). The actions are a discriminated union:

A cart reducer with four kinds of actionPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useReducer } from "react"; type CartState = { items: string[]; coupon: string | null }; type CartAction =  | { type: "add"; item: string }  | { type: "remove"; item: string }  | { type: "applyCoupon"; code: string }  | { type: "clear" }; function cartReducer(state: CartState, action: CartAction): CartState {  switch (action.type) {    case "add":      return { ...state, items: [...state.items, action.item] };    case "remove":      return { ...state, items: state.items.filter((item) => item !== action.item) };    case "applyCoupon":      return { ...state, coupon: action.code };    case "clear":      return { items: [], coupon: null };  }} function Cart() {  const [cart, dispatch] = useReducer(cartReducer, { items: [], coupon: null });//              ^?  return (    <div>      <p>{cart.items.join(", ") || "Empty"}{cart.coupon && ` with coupon ${cart.coupon}`}</p>      <button onClick={() => dispatch({ type: "add", item: "Tea" })}>Add tea</button>      <button onClick={() => dispatch({ type: "applyCoupon", code: "TEA10" })}>Use TEA10</button>      <button onClick={() => dispatch({ type: "clear" })}>Clear</button>    </div>  );} createRoot(document.getElementById("root")!).render(<Cart />);
  • Line 4 is the state: a list of items and a coupon code, or null.
  • Lines 6 to 10 list every action. Each has a type word and only the fields it needs: add needs an item, clear needs nothing.
  • Line 12 types the reducer: it takes a CartState and a CartAction, and returns a CartState.
  • Line 13 switches on action.type. Inside case "add", TypeScript has narrowed action to the add action, so line 15 can read action.item.
  • Line 26 creates the state from the reducer and a starting state. The query on line 27 shows dispatch: React.ActionDispatch<[action: CartAction]>: a function that takes one CartAction.
  • Lines 31 to 33 dispatch actions. Click Add tea twice and Use TEA10, and the page shows Tea, Tea with coupon TEA10.
Three action cards on the left: { type: "add", item: "Tea" }, { type: "applyCoupon", code }, and { type: "clear" }. Arrows lead from each into a card cartReducer, and an arrow from it leads to a card CartState. Below, a card { type: "ad", item: "Tea" } has no arrow and is marked not a CartAction.{ type: "add", item: "Tea" }{ type: "applyCoupon", code }{ type: "clear" }cartReducerCartState{ type: "ad", item: "Tea" }not a CartAction
dispatch sends one action at a time into cartReducer, which returns the next CartState. Each action is one shape from the CartAction union. Anything else, like a misspelled type, is stopped before it gets there.

Because dispatch only takes a CartAction, a wrong action is caught where you write it. Try some:

Playground: dispatch an action
A cart with an Order buttonPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useReducer } from "react"; type CartState = { items: string[]; coupon: string | null }; type CartAction =  | { type: "add"; item: string }  | { type: "remove"; item: string }  | { type: "applyCoupon"; code: string }  | { type: "clear" }; function cartReducer(state: CartState, action: CartAction): CartState {  switch (action.type) {    case "add":      return { ...state, items: [...state.items, action.item] };    case "remove":      return { ...state, items: state.items.filter((item) => item !== action.item) };    case "applyCoupon":      return { ...state, coupon: action.code };    case "clear":      return { items: [], coupon: null };  }} function Cart() {  const [cart, dispatch] = useReducer(cartReducer, { items: [], coupon: null });  return (    <div>      <p>{cart.items.join(", ") || "Empty"}{cart.coupon && ` with coupon ${cart.coupon}`}</p>      <button onClick={() => dispatch({ type: "add", item: "Tea" })}>Order</button>    </div>  );} createRoot(document.getElementById("root")!).render(<Cart />);
dispatch gets{ type: "add", item: "Tea" }
What happens
TypeScriptNo errors
After one clickTea
Try it yourself

An add action with its item: exactly one of the shapes CartAction allows. Click Order and the list shows Tea.

TypeScript checks the code for real, and the page row is what the cart shows after one click on Order. Each button changes only the action on line 30. Reset goes back to the add action.

The misspelled "ad" is the dangerous one: no case matches it, so the reducer returns undefined and the next draw crashes. TypeScript also checks the reducer itself. Leave out a case, and the function can end without returning anything:

A reducer with no case for clear (an error on purpose)Pop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useReducer } from "react"; type CartState = { items: string[]; coupon: string | null }; type CartAction =  | { type: "add"; item: string }  | { type: "remove"; item: string }  | { type: "applyCoupon"; code: string }  | { type: "clear" }; function cartReducer(state: CartState, action: CartAction): CartState {  switch (action.type) {    case "add":      return { ...state, items: [...state.items, action.item] };    case "remove":      return { ...state, items: state.items.filter((item) => item !== action.item) };    case "applyCoupon":      return { ...state, coupon: action.code };  }} function Cart() {  const [cart, dispatch] = useReducer(cartReducer, { items: [], coupon: null });  return (    <div>      <p>{cart.items.join(", ") || "Empty"}</p>      <button onClick={() => dispatch({ type: "add", item: "Tea" })}>Add tea</button>      <button onClick={() => dispatch({ type: "clear" })}>Clear</button>    </div>  );} createRoot(document.getElementById("root")!).render(<Cart />);

TypeScript marks line 12 on purpose: Function lacks ending return statement and return type does not include 'undefined' means that for a clear action, the switch finds no case and the function returns nothing, but line 12 promised a CartState. The return type is what makes this check work, so write it on reducers (Exhaustiveness checking shows more ways).

useRef for elements and values

Sometimes you need the real element on the page, for example to put the cursor in a search box. useRef gives you an object with one property, current. Pass it to an element's ref, and React puts the element in current once it's on the page:

A search button that focuses the search boxPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useRef } from "react"; function SearchBar() {  const inputRef = useRef<HTMLInputElement>(null);//      ^?  return (    <p>      <input ref={inputRef} placeholder="Search teas" />      <button onClick={() => inputRef.current?.focus()}>Search</button>    </p>  );} createRoot(document.getElementById("root")!).render(<SearchBar />);

Line 5 creates the ref with the element type in angle brackets and null to start, because there is no element yet. The query on line 6 shows React.RefObject<HTMLInputElement | null>: current is the input, or null before React draws it (Typing the DOM covers element types). Line 9 hands the ref to the input. Line 10 calls focus() with ?., which skips the call when current is null (Optional chaining). Click Search and the cursor moves into the box. Leave out the ? and TypeScript objects:

Focusing without the null check (an error on purpose)Pop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useRef } from "react"; function SearchBar() {  const inputRef = useRef<HTMLInputElement>(null);//      ^?  return (    <p>      <input ref={inputRef} placeholder="Search teas" />      <button onClick={() => inputRef.current.focus()}>Search</button>    </p>  );} createRoot(document.getElementById("root")!).render(<SearchBar />);

'inputRef.current' is possibly 'null' on line 10 means that TypeScript can't know a click only happens after the input is on the page. The ?. covers that case. Now try other ways to create the ref:

Playground: create the input's ref
A search bar that focuses its boxPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useRef } from "react"; function SearchBar() {  const inputRef = useRef<HTMLInputElement>(null);//      ^?  return (    <p>      <input ref={inputRef} placeholder="Search teas" />      <button onClick={() => inputRef.current?.focus()}>Search</button>    </p>  );} createRoot(document.getElementById("root")!).render(<SearchBar />);
inputRef isReact.RefObject<HTMLInputElement | null>
What happens
TypeScriptNo errors
When it runsSearch moves the cursor into the box
Try it yourself

A ref for an input that starts as null. current is HTMLInputElement | null, so line 10 checks with ?. before it calls focus.

TypeScript checks the code for real. Each button changes only line 5, and the query on line 6 shows the ref's type. Reset goes back to useRef<HTMLInputElement>(null).

Only useRef<HTMLInputElement>(null) names the element and starts empty. A ref can also hold any value that should survive between draws without causing one, like a timer:

A save button that waits for a pausePop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useRef } from "react"; function SaveButton() {  const timer = useRef<number | null>(null);  function save() {    if (timer.current !== null) window.clearTimeout(timer.current);    timer.current = window.setTimeout(() => console.log("Saved"), 500);  }  return <button onClick={save}>Save</button>;} createRoot(document.getElementById("root")!).render(<SaveButton />);

Line 5 creates a ref for a timer id: a number, or null when no timer is waiting. Line 7 cancels the waiting timer, if there is one. Line 8 starts a new one and stores its id. window.setTimeout returns a number in the browser's types, so it fits. Changing timer.current doesn't make React draw again; that's the difference from state. Click Save three times quickly and the console shows Saved once, half a second after the last click (Debounce & throttle explains the pattern).

Typed context

Some values are needed by many components: the theme, the signed-in user, the cart. Passing them as props through every layer is tiring. Context lets a component put a value above a part of the page, and any component below read it. You make one with createContext, put it above with <ThemeContext value=…>, and read it with useContext:

A theme that any component can readPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { createContext, useContext } from "react"; type Theme = "light" | "dark"; const ThemeContext = createContext<Theme>("light"); function ThemeLabel() {  const theme = useContext(ThemeContext);//      ^?  return <p>Theme: {theme}</p>;} createRoot(document.getElementById("root")!).render(  <ThemeContext value="dark">    <ThemeLabel />  </ThemeContext>);

Line 4 lists the themes. Line 6 creates the context with the type Theme and a default, "light", which components get when nothing above them gives a value. Line 9 reads it, and the query on line 10 shows const theme: Theme. Lines 15 to 17 put "dark" above ThemeLabel, so the page shows Theme: dark. React 19 lets you use the context itself as the tag; older code writes <ThemeContext.Provider value=…>, which still works. The value is checked like any prop:

A theme with a typo (an error on purpose)Pop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { createContext, useContext } from "react"; type Theme = "light" | "dark"; const ThemeContext = createContext<Theme>("light"); function ThemeLabel() {  const theme = useContext(ThemeContext);//      ^?  return <p>Theme: {theme}</p>;} createRoot(document.getElementById("root")!).render(  <ThemeContext value="drak">    <ThemeLabel />  </ThemeContext>);

Type '"drak"' is not assignable to type 'Theme' on line 15 means the value isn't one of the two themes. Some contexts have no sensible default. A cart only exists inside a cart provider, so its context often starts as null:

Reading a context that may be null (an error on purpose)Pop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { createContext, useContext } from "react"; type CartContextValue = { items: string[]; add: (item: string) => void }; const CartContext = createContext<CartContextValue | null>(null); function CartBadge() {  const cart = useContext(CartContext);  return <p>Cart: {cart.items.length}</p>;} createRoot(document.getElementById("root")!).render(<CartBadge />);

Line 6 says the context holds a CartContextValue or null, and starts as null. Line 10 reads cart.items, and TypeScript marks it on purpose: 'cart' is possibly 'null'. It's right: nothing above CartBadge gives the context a value, so this code throws when it runs. The usual fix is a small hook that checks once:

A useCart hook that removes nullPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { createContext, useContext } from "react"; type CartContextValue = { items: string[]; add: (item: string) => void }; const CartContext = createContext<CartContextValue | null>(null); function useCart() {  const cart = useContext(CartContext);  if (!cart) throw new Error("useCart must be used inside a CartContext");  return cart;//       ^?} function CartBadge() {  const cart = useCart();  return <p>Cart: {cart.items.length}</p>;} const twoItems: CartContextValue = { items: ["Tea", "Cake"], add: () => {} }; createRoot(document.getElementById("root")!).render(  <CartContext value={twoItems}>    <CartBadge />  </CartContext>);

Line 9 reads the context. Line 10 throws a clear error when there is no value, so after it, cart can't be null: the query on line 12 shows const cart: CartContextValue. Line 16 uses the hook, and line 17 reads items with no check. Lines 23 to 25 give the context a cart with two items, and the page shows Cart: 2.

Top, a card useContext(CartContext), with the type CartContextValue | null beside it. An arrow down leads to a card if (!cart) throw, with the note null stops here. An arrow down leads to a card return cart, with the type CartContextValue beside it.useContext(CartContext)CartContextValue | nullif (!cart) throw …null stops herereturn cartCartContextValue
useContext gives back CartContextValue | null, because the context starts as null. The check on line 10 throws for null, so after it, cart is a CartContextValue, and so is what useCart returns.

Typing your own hooks

A custom hook is your own function whose name starts with use and that calls other hooks, like useCart above. TypeScript types it like any function. One return value needs care: a pair, the way useState returns one.

A toggle hook that returns an array (an error on purpose)Pop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useState } from "react"; function useToggle(initial: boolean) {  const [on, setOn] = useState(initial);  const toggle = () => setOn((current) => !current);  return [on, toggle];} function FilterPanel() {  const [open, toggleOpen] = useToggle(false);//       ^?  return <button onClick={toggleOpen}>{open ? "Hide filters" : "Show filters"}</button>;} createRoot(document.getElementById("root")!).render(<FilterPanel />);

Line 7 returns [on, toggle]. For an array written like that, TypeScript works out an array whose items can each be either type: (boolean | (() => void))[]. So the query on line 12 shows open: boolean | (() => void), and line 13 is marked on purpose: Type 'boolean | (() => void)' is not assignable to type 'MouseEventHandler<HTMLButtonElement> | undefined' means toggleOpen might be a boolean, and a click handler can't be one. Add as const:

The toggle hook with as constPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { useState } from "react"; function useToggle(initial: boolean) {  const [on, setOn] = useState(initial);  const toggle = () => setOn((current) => !current);  return [on, toggle] as const;} function FilterPanel() {  const [open, toggleOpen] = useToggle(false);//       ^?  return <button onClick={toggleOpen}>{open ? "Hide filters" : "Show filters"}</button>;} createRoot(document.getElementById("root")!).render(<FilterPanel />);

as const on line 7 makes the return a tuple: a fixed pair, a boolean first and a function second (const assertions explains as const). The query now shows const open: boolean, and line 13 is fine. Click the button and it changes from Show filters to Hide filters. Returning an object, like { on, toggle }, also works, and callers take the names they want. The table sums up the hooks. Read each row as: this hook, the type it gives you, and when to write a type yourself.

Hooks and their types
The hookWhat its type comes fromWrite a type when
useStateThe starting value, or the type in angle brackets.The start is null, [], or one of a few words.
useReducerThe reducer's state and action types.Always type the reducer: its state, its actions, and its return.
useRefThe type in angle brackets and the starting value.It holds an element: useRef<HTMLInputElement>(null).
useContextThe type of the context from createContext.The default is null: createContext<T | null>(null).
Your own hookWhat it returns, like any function.It returns a pair: add as const.

A cart shared with context

A real shop puts these together: a reducer for the cart, a context to share it, and a hook to read it. Any component can then show or change the cart, with no props passed down:

A cart provider with a reducer, a context, and a hookPop out in the code editor (opens in a new tab)TSX
import { createRoot } from "react-dom/client";import { createContext, useContext, useReducer } from "react";import type { ReactNode } from "react"; type CartAction = { type: "add"; item: string } | { type: "clear" }; function cartReducer(items: string[], action: CartAction): string[] {  switch (action.type) {    case "add":      return [...items, action.item];    case "clear":      return [];  }} type CartContextValue = {  items: string[];  dispatch: (action: CartAction) => void;}; const CartContext = createContext<CartContextValue | null>(null); function CartProvider({ children }: { children: ReactNode }) {  const [items, dispatch] = useReducer(cartReducer, []);  return <CartContext value={{ items, dispatch }}>{children}</CartContext>;} function useCart() {  const cart = useContext(CartContext);  if (!cart) throw new Error("useCart must be used inside <CartProvider>");  return cart;} function CartHeader() {  const { items } = useCart();  return <h2>Cart: {items.length}</h2>;} function TeaRow() {  const { dispatch } = useCart();  return <button onClick={() => dispatch({ type: "add", item: "Tea" })}>Add tea</button>;} createRoot(document.getElementById("root")!).render(  <CartProvider>    <CartHeader />    <TeaRow />  </CartProvider>);
  • Lines 5 to 14 are a small reducer: add an item, or clear the list.
  • Lines 16 to 19 describe what the context shares: the items, and dispatch, typed as a function that takes a CartAction.
  • Line 21 creates the context, starting as null.
  • Lines 23 to 26 are CartProvider: it creates the state with useReducer and shares it through the context. Its children are a ReactNode, as in the previous lesson.
  • Lines 28 to 32 are the useCart hook, which removes null.
  • Lines 34 to 42 use it. CartHeader reads the items, and TeaRow dispatches an add action, checked against CartAction.

The page shows Cart: 0. Click Add tea twice and it shows Cart: 2.

Top, a card <CartProvider>. Arrows lead down to two cards, <CartHeader /> and <TeaRow />, with no props on the arrows. Under each card: useCart(). A note between the arrows says no props passed down.<CartProvider>no props passed down<CartHeader />useCart()<TeaRow />useCart()
CartProvider puts the cart in CartContext once. CartHeader and TeaRow each read it with useCart(), typed CartContextValue, with no props passed down to them.

Now sort some hook calls. Each card runs inside a component, with React's hooks imported:

Error or fine?
  • const [count, setCount] = useState(0); setCount(count + 1);
  • const [count, setCount] = useState(0); setCount("1");
  • const [user, setUser] = useState(null); setUser({ name: "Asha" });
  • const [user, setUser] = useState<{ name: string } | null>(null); setUser({ name: "Asha" });
  • const ref = useRef<HTMLInputElement>(null); ref.current.focus();
  • const ref = useRef<HTMLInputElement>(null); ref.current?.focus();
  • const ref = useRef<HTMLInputElement>();
  • const Theme = createContext("light"); const theme: string = useContext(Theme);
Try it yourself
0 of 8 correct

Sort each line by what TypeScript says about it. Each card is checked on its own, inside a component.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "useState(null) means null for now." No. To TypeScript it means null forever. Write useState<User | null>(null) to say what comes later.
  • "An empty array holds anything." No. useState([]) holds never[], an array of nothing. Write the item type: useState<string[]>([]).
  • "A ref's current is the element as soon as I create it." No. It's null until React puts the element on the page, so its type includes null.
  • "Changing ref.current redraws the component." No. Only state changes do. A ref keeps a value quietly.
  • "useContext fails when there's no value above." No. It returns the default from createContext. That's why a null default plus a checking hook is common.
  • "A hook that returns [a, b] returns a pair." No. TypeScript sees an array of either type. Add as const to make it a tuple.
  • "A reducer doesn't need a return type." It works without one, but the return type is what makes TypeScript notice a missing case.

Practice exercises

Each exercise is a small React component. Press Edit & run on the starter code: the errors update as you type, and the page shows what it does. Try first, then use the hints.

Exercise 1 · Warm-upRead a state's type

What type does TypeScript give items?

import { createRoot } from "react-dom/client";import { useState } from "react"; function Wishlist() {  const [items, setItems] = useState(["Green tea", "Lemon cake"]);  return <p onClick={() => setItems([])}>{items.join(" and ")}</p>;} createRoot(document.getElementById("root")!).render(<Wishlist />);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind the line TypeScript marks

    A price editor with one wrong call. Which line will TypeScript mark?

    import { createRoot } from "react-dom/client";
    import { useState } from "react";
    
    function PriceEditor() {
      const [price, setPrice] = useState(4);
      return (
        <p>
          Cake: ${price}{" "}
          <button onClick={() => setPrice(price - 1)}>Cheaper</button>
          <button onClick={() => setPrice("5")}>Reset</button>
        </p>
      );
    }
    
    createRoot(document.getElementById("root")!).render(<PriceEditor />);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeType an empty list

      Line 8 is marked. Fix the state's type, then press Search oolong twice. What does the page show after Recent:?

      import { createRoot } from "react-dom/client";import { useState } from "react"; function RecentSearches() {  const [searches, setSearches] = useState([]);  return (    <p>      <button onClick={() => setSearches(["oolong", ...searches])}>Search oolong</button>{" "}      Recent: {searches.join(", ")}    </p>  );} createRoot(document.getElementById("root")!).render(<RecentSearches />);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeHandle every action

        The reducer misses one action, so line 6 is marked. Add the missing case so Reset sets the count to 0. What does the page show after you click Reset?

        import { createRoot } from "react-dom/client";import { useReducer } from "react"; type CounterAction = { type: "increment" } | { type: "decrement" } | { type: "reset" }; function counterReducer(count: number, action: CounterAction): number {  switch (action.type) {    case "increment":      return count + 1;    case "decrement":      return count - 1;  }} function Counter() {  const [count, dispatch] = useReducer(counterReducer, 3);  return (    <p>      {count} cakes <button onClick={() => dispatch({ type: "increment" })}>One more</button>      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>    </p>  );} createRoot(document.getElementById("root")!).render(<Counter />);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFocus the coupon box

          Make the button put the cursor in the coupon box, with no errors. What type does couponRef get after your fix?

          import { createRoot } from "react-dom/client";import { useRef } from "react"; function CouponBox() {  const couponRef = useRef(null);  return (    <p>      <input ref={couponRef} aria-label="Coupon code" />      <button onClick={() => couponRef.current.focus()}>I have a coupon</button>    </p>  );} createRoot(document.getElementById("root")!).render(<CouponBox />);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWrite a hook that removes null

            Greeting reads a context that may be null. Write a useUser hook that removes null, and use it. What does the page show?

            import { createRoot } from "react-dom/client";import { createContext, useContext } from "react"; type User = { name: string }; const UserContext = createContext<User | null>(null); function Greeting() {  const user = useContext(UserContext);  return <p>Hello, {user.name}</p>;} createRoot(document.getElementById("root")!).render(  <UserContext value={{ name: "Asha" }}>    <Greeting />  </UserContext>);

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The typing state and hooks quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What type does tags get here?

                Read the code, then predictPop out in the code editor (opens in a new tab)TSX
                import { createRoot } from "react-dom/client";
                import { useState } from "react";
                
                function Tags() {
                  const [tags, setTags] = useState([]);
                  return <p onClick={() => setTags([])}>{tags.length} tags</p>;
                }
                
                createRoot(document.getElementById("root")!).render(<Tags />);

                Choose an answer to see the explanation.

              2. Question 2 of 7Why does TypeScript mark line 12 of this basket?

                Read the code, then predictPop out in the code editor (opens in a new tab)TSX
                import { createRoot } from "react-dom/client";
                import { useReducer } from "react";
                
                type BasketAction = { type: "add"; item: string } | { type: "empty" };
                
                function basketReducer(items: string[], action: BasketAction): string[] {
                  return action.type === "add" ? [...items, action.item] : [];
                }
                
                function Basket() {
                  const [items, dispatch] = useReducer(basketReducer, []);
                  return <button onClick={() => dispatch({ type: "add" })}>{items.length} in basket</button>;
                }
                
                createRoot(document.getElementById("root")!).render(<Basket />);

                Choose an answer to see the explanation.

              3. Question 3 of 7What does this price show, with no CurrencyContext tag above it?

                Read the code, then predictPop out in the code editor (opens in a new tab)TSX
                import { createRoot } from "react-dom/client";
                import { createContext, useContext } from "react";
                
                const CurrencyContext = createContext<"USD" | "EUR">("USD");
                
                function Price() {
                  const currency = useContext(CurrencyContext);
                  return <p>3 {currency}</p>;
                }
                
                createRoot(document.getElementById("root")!).render(<Price />);

                Choose an answer to see the explanation.

              4. Question 4 of 7What return type does TypeScript work out for useCounter?

                Read the code, then predictPop out in the code editor (opens in a new tab)TSX
                import { useState } from "react";
                
                export function useCounter() {
                  const [count, setCount] = useState(0);
                  return [count, () => setCount(count + 1)];
                }

                Choose an answer to see the explanation.

              5. Question 5 of 7After const inputRef = useRef<HTMLInputElement>(null), what is the type of inputRef.current?

                Choose an answer to see the explanation.

              6. Question 6 of 7A click handler sets timer.current = 5 on a ref. What does React do?

                Choose an answer to see the explanation.

              7. Question 7 of 7When should you pass a type argument, like useState<T>(…)?

                Choose an answer to see the explanation.

              Key takeaways

              • State gets its type from its starting value, and its setter only takes that type.
              • When the start is a placeholder, say the type: useState<User | null>(null), useState<string[]>([]).
              • Type a reducer's actions as a discriminated union, and give the reducer a return type.
              • useRef<HTMLInputElement>(null) gives a current that may be null; check it with ?..
              • Type context with createContext<T>; with a null default, read it through a hook that checks.
              • A custom hook that returns a pair needs as const.

              Type what a component remembers, and every change to it is checked.
              State, actions, refs, and context each get a type once, and TypeScript holds every setter, dispatch, and reader to it.

              Coming next: Branded types, the first of the advanced patterns: how to stop values of the same type, like a user id and an order id, from being mixed up.

              CompleteFrontend Clear concepts. Working examples.