React and TypeScript fit together unusually well, because a component is a function and its props are an object — both of which the type system already describes precisely. Most of what follows is applying earlier lessons rather than learning new syntax.
The one genuinely React-specific idea is at the end: a context pattern that removes an
| undefined from every consumer in the app.
Props
A component's props are one object, so its type is one object type:
interface Props {
product: Product;
onSelect: (product: Product) => void;
}export const ProductCard = memo(function ProductCard({ product, onSelect }: Props) {Destructure in the signature, annotate the whole parameter. That is the entire convention, and it holds for every component in a codebase.
For one or two props an inline type is fine:
export function AppNavbar({ onOpenCart }: { onOpenCart: () => void }) { /* … */ }Note there is no React.FC here, and that is deliberate. It used to be the standard
way to type a component; it is now generally discouraged, because it implicitly added a
children prop to components that did not take one and made generic components awkward.
Annotating the parameter is simpler and says more.
children
Anything between a component's tags arrives as children, and the type for "whatever
React can render" is ReactNode:
import type { ReactNode } from 'react';
export function AuthProvider({ children }: { children: ReactNode }) {ReactNode covers elements, strings, numbers, arrays, null and
undefined — which is what you want, because all of those are legal children.
Two neighbours worth distinguishing. ReactElement is specifically a JSX element, so
a string child would be rejected — occasionally what you want, usually too strict.
JSX.Element is what a component returns. When in doubt, ReactNode.
Note also the import type on that line. ReactNode is a type and nothing
else, so under verbatimModuleSyntax —
lesson 17 — it has to say so.
useState
Usually you write nothing:
const [loading, setLoading] = useState(false);
const [initialising, setInitialising] = useState(true);loading is boolean, inferred from the initial value. The type argument
is needed when the initial value does not represent the full range — which is almost always the
null case:
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);Without <User | null>, the state would infer as null and
setUser(someUser) would be an error. The rule: annotate when the initial value
is null, undefined or an empty array, because in each case the
initial value tells the compiler less than the truth.
The setter also accepts a function, and both forms are typed for you:
setCount((n) => n + 1); // n is numberuseRef has two shapes
Which one you get depends on the initial value, and the difference is the source of a common error.
const inputRef = useRef<HTMLInputElement>(null); // RefObject — .current is read-only
const timerRef = useRef<number | undefined>(undefined); // MutableRefObjectThe first is for DOM elements: you pass it to ref={inputRef} and React assigns it,
so your code only reads. The second is a mutable box for instance-ish values you assign yourself —
a timer id, a previous value, a flag.
Either way .current may be null, so it needs checking before use. That
is honest rather than annoying: the element does not exist until after the first render.
Events
Handlers attached inline are typed by context and need no annotation:
<button type="button" onClick={() => onSelect(product)}>When a handler is declared separately, the context is gone and you supply the type:
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
}
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setEmail(event.target.value); // typed as string
}The type parameter is the element, and it is what makes event.target.value a
string rather than an error. Get it wrong — HTMLElement instead of
HTMLInputElement — and value does not exist.
The common ones are ChangeEvent, FormEvent,
MouseEvent and KeyboardEvent. If you cannot remember which, write the
handler inline first, hover the parameter, and copy what the editor says.
The context pattern
This is the piece worth taking away, because the naive version pushes a null check into every consumer.
A context needs a default value, and for a context holding real application state there is no sensible default — the value only exists inside the provider. So you say so:
const AuthContext = createContext<AuthContextValue | undefined>(undefined);Correct, and now useContext(AuthContext) returns
AuthContextValue | undefined everywhere, and forty components have to handle a case
that cannot happen if the provider is mounted.
The fix is to check once, in a custom hook, and let narrowing do the rest:
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used inside an <AuthProvider>');
}
return context;
}Three things happen there. The return type is AuthContextValue with no
undefined, because the if has narrowed it —
lesson 10 again. Consumers write
const { user, isAdmin } = useAuth() and get a value they can use immediately. And a
component rendered outside the provider fails with a sentence explaining what is wrong, rather than
reading properties of undefined somewhere further in.
The value itself is an interface like any other:
interface AuthContextValue {
user: User | null;
isAuthenticated: boolean;
isAdmin: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, fullName: string) => Promise<void>;
logout: () => void;
error: string | null;
loading: boolean;
/** True until the stored token has been checked against the API on first load. */
initialising: boolean;
}Note user: User | null stays nullable. That one is a real state — nobody is
logged in — and the type should say so.
Async, and typing what comes back
The generic HTTP wrapper from lesson 13 is what makes data-loading components readable:
const me = await api.get<User>('/api/auth/me', { auth: true, signal: controller.signal });
setUser(me);me is a User, so setUser accepts it, and if the state and
the endpoint disagree the build says so.
The error path is the unknown narrowing from
lesson 4, and the app does it inline:
} catch (err) {
const message =
err instanceof ApiError ? err.message : 'Could not reach the server. Is the API running?';
setError(message);Typed Redux hooks
If you use Redux Toolkit, do this once and forget it:
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();Components import useAppSelector rather than useSelector, and state is
typed without a generic at every call site. The comment in the app explains why the types are
derived rather than declared: add a slice and RootState grows by itself.
Typing a custom hook
A hook is a function, so it is typed like one — but the return shape deserves a decision rather than an inference.
Return an object when the values have names:
interface UseOrdersResult {
orders: Order[];
loading: boolean;
error: string | null;
reload: () => void;
}
export function useOrders(): UseOrdersResult { /* … */ }Return a tuple only when the caller should be free to rename the parts
positionally, which is why useState does it. And if you do return a tuple, say so —
otherwise TypeScript infers an array of the union, as
lesson 5 covered:
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle] as const; // readonly [boolean, () => void]
}Without as const the return type is (boolean | (() => void))[], and
destructuring it gives both names that useless union.
The useAuth hook above is the object form, and it does the other thing a good custom
hook does: it turns an awkward type at the boundary into a comfortable one for every caller.
Extending an element's props
A component that wraps a native element should accept everything that element accepts. Intersect with React's own prop types rather than listing them:
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant: 'primary' | 'danger';
loading?: boolean;
};
export function Button({ variant, loading, ...rest }: ButtonProps) {
return <button className={variant} disabled={loading} {...rest} />;
}onClick, type, aria-label and everything else a button
takes now typecheck, and the rest spread carries them through. The equivalents for other elements
are InputHTMLAttributes, AnchorHTMLAttributes and so on, plus
ComponentProps<'button'> if you would rather not remember the names.
Use Omit when you need to replace one of the native props — a
Select whose onChange hands back a parsed value rather than an event is
Omit<SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> & { onChange: (value: T) => void }.
Generic components
A component can take a type parameter, which is how you write a reusable table or list:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => ReactNode;
}
export function List<T>({ items, renderItem }: ListProps<T>) {
return <ul>{items.map((item, i) => <li key={i}>{renderItem(item)}</li>)}</ul>;
}Used as <List items={products} renderItem={(p) => p.name} />, and
p is a Product — inferred from items, with nothing written at
the call site.
Remember the .tsx wrinkle from lesson 13: an arrow function needs
<T,> with the trailing comma. A function declaration, as above, does
not.
What actually goes wrong
Four errors account for most of the time people lose here, and all four have short answers.
"Type 'null' is not assignable…" from useState. The initial value
was null and no type argument was given. Add <User | null>.
"Property 'value' does not exist on type 'EventTarget'." The event's element
parameter is wrong or missing. React.ChangeEvent<HTMLInputElement>, not
Event.
"'X' is possibly 'undefined'" on a context value. You called
useContext directly instead of going through the hook that checks. That is what the
hook is for.
A key prop error on a mapped list. Usually the element type is wider than you
think — often any[] from an untyped source, or the empty-array inference from
lesson 5. Look at where the array came from
rather than at the JSX.
The pattern in all four: the error is where the type was established, not where it was used. That is the general skill this track has been building — read an error as a question about provenance.
None of this needs a library, a plugin or a convention beyond annotating your props. React's own types do the rest, and most of what looks React-specific here is a lesson from earlier in the track wearing different clothes.
Next
Interview Questions — the last lesson, answering the questions a TypeScript role actually asks against the twenty before it.