Contents
20 Chapter

Context and generic components

Type-safety patterns for Context (null + wrapper hook), splitting state and dispatch, generic components, and the as prop of polymorphic components, all in one chapter.

Chapter 19 closed out the most common typing inside a component. This chapter steps one level up and looks at values shared across components (Context) and components that take many shapes of data (generics).

We put the JavaScript patterns from Chapter 12 (useContext) back on top of TypeScript, and on top of that we add generic components and polymorphic components (the as prop). We also touch on how this interacts with React 19’s ref-as-prop model.

Context type argument — initial value and use site differ #

createContext takes the initial value as-is and infers its type. The problem is that the initial value often is not meaningful. How do you express “a value that means something only inside a Provider and should not be used outside it”?

There are three common patterns, each with different trade-offs.

1) Give a meaningful default #

The simplest one. Give a meaningful default that lets the component work even without a Provider. It fits cases like a theme, where “the default is light, override with a Provider when needed”.

Context with a default
import { createContext, useContext } from 'react';

type Theme = 'light' | 'dark';

const ThemeContext = createContext<Theme>('light');

function useTheme() {
  return useContext(ThemeContext);
}

// use site
function Toolbar() {
  const theme = useTheme();    // Theme — always a meaningful value
  return <div className={theme}>...</div>;
}

The upside is simplicity. The downside is that “things keep working even if you forget the Provider”, so mistakes are caught late.

2) Start with null + a safe useContext helper #

When the value is only meaningful inside a Provider (user info, cart, dispatch, and so on), start with null and provide a helper that runs the check once at the use site. This is the pattern used most in practice.

null start + helper
type User = { id: string; name: string };

const UserContext = createContext<User | null>(null);

export function useUser() {
  const user = useContext(UserContext);
  if (user === null) {
    throw new Error('useUser must be called inside a UserProvider');
  }
  return user;     // narrowed to User from here on
}

// use site
function Profile() {
  const user = useUser();      // User (no null branch needed)
  return <p>{user.name}</p>;
}

The helper blocks the case so calling components do not have to write if (user === null) every time. The throw inside the helper is what does the work. TypeScript drops the null possibility afterwards.

This book uses this pattern as the default. Chapter 32 (Auth and sessions) takes the same shape when we build useAuth.

3) Start with a cast — not recommended #

You sometimes see the cast pattern createContext<User>({} as User). The code is short, but at the use site, when the Provider is missing, the empty object leaks straight through and becomes a runtime bug. Almost always, pattern 2 is safer.

When state and dispatch flow together — split into two Contexts #

When you flow both state and a setter through a Context, splitting state and dispatch into two Contexts reduces re-render cost. You prevent components that use only dispatch from re-rendering just because the state changed.

state Context + dispatch Context
import { createContext, useContext, useReducer } from 'react';

type State = { count: number };
type Action = { type: 'inc' } | { type: 'dec' };

const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<React.Dispatch<Action> | null>(null);

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'inc': return { count: state.count + 1 };
    case 'dec': return { count: state.count - 1 };
  }
}

export function CounterProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}

export function useCounterState() {
  const v = useContext(StateContext);
  if (v === null) throw new Error('use inside CounterProvider only');
  return v;
}

export function useCounterDispatch() {
  const v = useContext(DispatchContext);
  if (v === null) throw new Error('use inside CounterProvider only');
  return v;
}

A component that uses only useCounterDispatch does not re-render when count changes. It may be over-optimization in a small app, but if you are flowing frequently changing state through a Context, this is worth considering. It is the same value-splitting pattern from Chapter 12 made more explicit on top of TypeScript.

Generic components — components that take any data #

Components like a list, select, or table naturally invite “take any data and render it”. Just as you can put generics on a function, you can put them on a component.

generic List component
type ListProps<T> = {
  items: readonly T[];
  renderItem: (item: T) => React.ReactNode;
  keyOf: (item: T) => string | number;
};

function List<T>({ items, renderItem, keyOf }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyOf(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// use site
type Todo = { id: string; text: string };

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <List
      items={todos}
      keyOf={(t) => t.id}
      renderItem={(t) => <span>{t.text}</span>}
    />
  );
}

The key is putting the generic parameter after the function keyword like function List<T>(props: ListProps<T>). At the call site, T is inferred from the type of items.

Note
In .tsx files, an arrow function with a generic can have its <T> confused for a JSX tag. That is why generic components almost always use the function declaration form.

Generic components + a constraint (extends) #

Add a constraint saying what shape T must have, and you can use that field directly in the component body.

constraint — force an id field
type WithId = { id: string };

type ListProps<T extends WithId> = {
  items: readonly T[];
  renderItem: (item: T) => React.ReactNode;
};

function List<T extends WithId>({ items, renderItem }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{renderItem(item)}</li>   // item.id is usable
      ))}
    </ul>
  );
}

// the call site no longer needs keyOf
<List items={todos} renderItem={(t) => <span>{t.text}</span>} />

This is nice because the call site gets shorter, but now List requires data with id: string. Both patterns have their place. When flexibility matters more, the keyOf function fits; when safety and a short call site matter more, the constraint fits.

Polymorphic components — swapping tags with the as prop #

You often build components with the same design that should render as <button> somewhere, <a> elsewhere, and <Link> in another place. These are called polymorphic components, and capturing them properly in TypeScript takes some work.

It is an application of the discriminated union from Chapter 17.

polymorphic component — basic shape
import type { ElementType, ComponentPropsWithoutRef } from 'react';

type BoxProps<E extends ElementType> = {
  as?: E;
  children?: React.ReactNode;
} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'children'>;

function Box<E extends ElementType = 'div'>({
  as,
  children,
  ...rest
}: BoxProps<E>) {
  const Tag = as ?? 'div';
  return <Tag {...rest}>{children}</Tag>;
}

// use site
<Box>default div</Box>
<Box as="a" href="/about">like a link</Box>
<Box as="button" onClick={() => {}}>like a button</Box>

How it works, line by line:

  1. E extends ElementType — E is an HTML tag name ('div', 'a', …) or a component type
  2. ComponentPropsWithoutRef<E> — pulls in every prop of that tag / component
  3. Omit<..., 'as' | 'children'> — strips the props we already define ourselves to avoid collision
  4. The default generic E = 'div' — when you write just <Box>, the type lands on div

With this one pattern, href autocompletes correctly when as="a" and onClick autocompletes correctly when as="button".

Use polymorphic components only when you really need them #

The pattern is powerful, but types get complex quickly and editor autocomplete can slow down. For a design system library it pays off, but in normal app code it is often more readable to build two components like Button and LinkButton instead of going with as. Be aware of the trade-off when you reach for it.

A generic component that takes ref as a prop — React 19 #

The ref-as-prop model we touched on in Chapter 18 pairs naturally with generic components. Before React 19, combining forwardRef with generics was awkward; now you just take it as a prop.

generic + ref-as-prop
import type { Ref } from 'react';

type SelectProps<T> = {
  ref?: Ref<HTMLSelectElement>;
  options: readonly T[];
  getValue: (item: T) => string;
  getLabel: (item: T) => string;
};

function Select<T>({ ref, options, getValue, getLabel }: SelectProps<T>) {
  return (
    <select ref={ref}>
      {options.map((opt) => (
        <option key={getValue(opt)} value={getValue(opt)}>
          {getLabel(opt)}
        </option>
      ))}
    </select>
  );
}

// use site
type Country = { code: string; name: string };
const countries: Country[] = [
  { code: 'kr', name: 'Korea' },
  { code: 'us', name: 'United States' },
];

function CountryPicker() {
  const ref = useRef<HTMLSelectElement>(null);
  return (
    <Select
      ref={ref}
      options={countries}
      getValue={(c) => c.code}
      getLabel={(c) => c.name}
    />
  );
}

The old model that combined forwardRef with generics (an area where generics did not infer well) untangles cleanly in this model. Chapter 28 (React 19 features) revisits this change once more.

Generic hooks — briefly #

The same generic pattern applies to hooks. A common example is “a hook holding an API response”, which we will look at in earnest in the next Chapter 21 Typing fetch and API responses. For a preview of the shape:

generic hook preview
function useResource<T>(url: string): { data: T | null; loading: boolean } {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(url)
      .then((r) => r.json() as Promise<T>)
      .then((d) => {
        setData(d);
        setLoading(false);
      });
  }, [url]);

  return { data, loading };
}

// use site
const { data } = useResource<User>('/api/me');
// data: User | null

The dangerous part of this code is actually r.json() as Promise<T>. Nothing has verified that the server actually returned T. Chapter 21 shows how to plug that hole safely with zod.

Try it yourself #

Let’s build a small auth Context in TypeScript.

src/AuthContext.tsx:

src/AuthContext.tsx
import { createContext, useContext, useState, useCallback } from 'react';
import type { ReactNode } from 'react';

type User = {
  id: string;
  name: string;
};

type AuthContextValue = {
  user: User | null;
  login: (name: string) => void;
  logout: () => void;
};

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

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

  const login = useCallback((name: string) => {
    setUser({ id: crypto.randomUUID(), name });
  }, []);

  const logout = useCallback(() => setUser(null), []);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const v = useContext(AuthContext);
  if (v === null) {
    throw new Error('useAuth must be called inside an AuthProvider');
  }
  return v;
}

src/App.tsx:

src/App.tsx
import { AuthProvider, useAuth } from './AuthContext';

function LoginForm() {
  const { user, login, logout } = useAuth();

  if (user) {
    return (
      <div>
        <p>Hello, {user.name}!</p>
        <button onClick={logout}>Log out</button>
      </div>
    );
  }

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      const formData = new FormData(e.currentTarget);
      const name = formData.get('name');
      if (typeof name === 'string' && name.length > 0) login(name);
    }}>
      <input name="name" placeholder="name" />
      <button type="submit">Log in</button>
    </form>
  );
}

function App() {
  return (
    <AuthProvider>
      <LoginForm />
    </AuthProvider>
  );
}

export default App;

Save and confirm it works. Then drop <AuthProvider> and leave just <LoginForm />, and see what happens. The throw inside useAuth fires and a clear error shows up in the console. You can compare directly why the cast-start pattern ({} as AuthContextValue) is risky.

Exercises #

  1. Split the AuthContext above into a state Context and a dispatch Context. Put user in the state Context and login / logout in the dispatch Context, with useAuthState and useAuthDispatch helpers. Add console.log('rendered') inside LoginForm to confirm that a child using only dispatch does not re-render when the user changes.
  2. Build a generic Select component using the ref-as-prop pattern from this chapter, and call it from a parent in two cases: a string array and a { code: string; name: string } array. Confirm that the getValue / getLabel signatures are automatically inferred in both calls.
  3. Compare the trade-off between a polymorphic component and splitting into two. Build a polymorphic button like Box and a split version with Button / LinkButton, then compare the call sites. Write a short note on which feels better across three axes: autocomplete speed, clarity of the call site, and readability of error messages.

In one line: For Context initial values, null + a helper pattern is the practical default. Splitting state and dispatch into two Contexts reduces re-renders. Generic components take the function List<T>(...) form (arrow functions collide with JSX). When you want a shorter call site, add a constraint like T extends WithId. Polymorphic components use the as prop + ComponentPropsWithoutRef<E> combo. Powerful, but with a cost — reach for it only when needed. React 19’s ref-as-prop model pairs naturally with generic components.

Next chapter #

In the next Chapter 21 Typing fetch and API responses, we cover how to safely fill the dangerous cast (r.json() as Promise<T>) from this chapter’s useResource with zod. We also touch on the meaning fetch takes in the Server Components environment of Part 4 (Modern Next.js) — the foundation for a new model where the client useEffect + fetch disappears.

X