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.
- 01Type stateKnow when useState works out the type, and when to write one, like useState<User | null>(null).
- 02Type reducers and refsType a reducer's actions as a union, and create refs for elements with useRef<HTMLInputElement>(null).
- 03Type 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.
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:
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 />);event.target.value1 error: line 9With the 2 in your cart: 42A 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.
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.
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.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
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:
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 />);null2 errors: lines 13 and 14Not signed in, then Signed in as Asha after a clickFrom 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'.
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:
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:
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:
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:
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.
| You write | The state's type | When that isn't enough |
|---|---|---|
useState(0) | number | It's enough: the start shows the type. |
useState("") | string | When only a few words are allowed: useState<SaveStatus>("idle"). |
useState(null) | null | Always, if something else comes later: useState<User | null>(null). |
useState([]) | never[] | Always: say what goes in, useState<string[]>([]). |
useState(false) | boolean | It'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:
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
typeword and only the fields it needs:addneeds anitem,clearneeds nothing. - Line 12 types the reducer: it takes a
CartStateand aCartAction, and returns aCartState. - Line 13 switches on
action.type. Insidecase "add", TypeScript has narrowedactionto the add action, so line 15 can readaction.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 oneCartAction. - Lines 31 to 33 dispatch actions. Click Add tea twice and Use TEA10, and the page shows
Tea, Tea with coupon TEA10.
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:
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 />);{ type: "add", item: "Tea" }No errorsTeaAn add action with its item: exactly one of the shapes CartAction allows. Click Order and the list shows Tea.
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:
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:
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:
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:
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 />);React.RefObject<HTMLInputElement | null>No errorsSearch moves the cursor into the boxA ref for an input that starts as null. current is HTMLInputElement | null, so line 10 checks with ?. before it calls focus.
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:
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:
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:
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:
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:
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.
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.
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:
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.
| The hook | What its type comes from | Write a type when |
|---|---|---|
useState | The starting value, or the type in angle brackets. | The start is null, [], or one of a few words. |
useReducer | The reducer's state and action types. | Always type the reducer: its state, its actions, and its return. |
useRef | The type in angle brackets and the starting value. | It holds an element: useRef<HTMLInputElement>(null). |
useContext | The type of the context from createContext. | The default is null: createContext<T | null>(null). |
| Your own hook | What 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:
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 aCartAction. - Line 21 creates the context, starting as
null. - Lines 23 to 26 are
CartProvider: it creates the state withuseReducerand shares it through the context. Itschildrenare aReactNode, as in the previous lesson. - Lines 28 to 32 are the
useCarthook, which removesnull. - Lines 34 to 42 use it.
CartHeaderreads the items, andTeaRowdispatches an add action, checked againstCartAction.
The page shows Cart: 0. Click Add tea twice and it shows Cart: 2.
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:
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);
Sort each line by what TypeScript says about it. Each card is checked on its own, inside a component.
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([])holdsnever[], 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
nulluntil React puts the element on the page, so its type includesnull. - "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 anulldefault 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 constto 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.
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 />);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 />);The starting value is an array of strings, so TypeScript works out string[]. The query in the solution shows const items: string[].
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 />);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 />);Line 10 passes the text "5" to setPrice, which only takes a number, so TypeScript reports Argument of type 'string' is not assignable to parameter of type 'SetStateAction<number>'. setPrice(5) fixes it.
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 />);import { createRoot } from "react-dom/client";import { useState } from "react"; function RecentSearches() { const [searches, setSearches] = useState<string[]>([]); return ( <p> <button onClick={() => setSearches(["oolong", ...searches])}>Search oolong</button>{" "} Recent: {searches.join(", ")} </p> );} createRoot(document.getElementById("root")!).render(<RecentSearches />);useState<string[]>([]) says the list holds strings, so line 8 can add "oolong". After two clicks the page shows Recent: oolong, oolong.
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 />);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; case "reset": return 0; }} 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 />);With a reset case, every action returns a number, so the error on line 6 goes. Clicking Reset shows 0 cakes.
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 />);import { createRoot } from "react-dom/client";import { useRef } from "react"; function CouponBox() { const couponRef = useRef<HTMLInputElement>(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 />);useRef<HTMLInputElement>(null) makes couponRef a React.RefObject<HTMLInputElement | null>, and couponRef.current?.focus() handles 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>);import { createRoot } from "react-dom/client";import { createContext, useContext } from "react"; type User = { name: string }; const UserContext = createContext<User | null>(null); function useUser() { const user = useContext(UserContext); if (!user) throw new Error("useUser must be used inside a UserContext"); return user;} function Greeting() { const user = useUser(); return <p>Hello, {user.name}</p>;} createRoot(document.getElementById("root")!).render( <UserContext value={{ name: "Asha" }}> <Greeting /> </UserContext>);After if (!user) throw …, user is a User, so useUser returns a User and Greeting reads name with no check. The page shows Hello, Asha.
Check your understanding
Question 1 of 7What type does
tagsget here?Read the code, then predictimport { 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.
Question 2 of 7Why does TypeScript mark line 12 of this basket?
Read the code, then predictimport { 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.
Question 3 of 7What does this price show, with no CurrencyContext tag above it?
Read the code, then predictimport { 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.
Question 4 of 7What return type does TypeScript work out for
useCounter?Read the code, then predictimport { useState } from "react"; export function useCounter() { const [count, setCount] = useState(0); return [count, () => setCount(count + 1)]; }Choose an answer to see the explanation.
Question 5 of 7After
const inputRef = useRef<HTMLInputElement>(null), what is the type ofinputRef.current?Choose an answer to see the explanation.
Question 6 of 7A click handler sets
timer.current = 5on a ref. What does React do?Choose an answer to see the explanation.
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 acurrentthat may benull; check it with?..- Type context with
createContext<T>; with anulldefault, 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.