TypeScript – With React

September 2, 20268 min readUpdated 9/5/2026

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 verbatimModuleSyntaxlesson 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 number

useRef 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);   // MutableRefObject

The 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.