TypeScript + React in Practice #5: Context and generic components

3 min read

Through #4 Typing events and forms we wrapped up the most common typing inside components. This post goes one level up — values shared by multiple components (Context) and components that take various data shapes (generics).

Context type arguments — initial value vs use site differ #

createContext infers from the initial value. The problem is the initial value is often not a meaningful one. How do you express “a value meaningful only inside a Provider — not to be used outside”?

Three common patterns, with different trade-offs.

1) Provide a meaningful default #

The simplest. Provide a meaningful default that works without a Provider. Fits situations like a theme: “default light, override with 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 meaningful
  return <div className={theme}>...</div>;
}

Pro: simplicity. Con: “It works even if you forget the Provider,” so mistakes are caught late.

2) Start with null + a safe useContext helper #

When the value is meaningful only inside a Provider (e.g., user info, cart, dispatch), set the initial value to null and create a helper that checks once at the use site. This pattern is the most-used in real work.

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는 UserProvider 안에서만 호출하세요');
  }
  return user;     // narrowed to User from here
}

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

The helper saves consumers from writing if (user === null) every time. The throw in the helper is the trick — TypeScript excludes the null possibility afterward.

3) Starting with a cast — not recommended #

You sometimes see createContext<User>({} as User) — a fake initial value via cast. Code is short, but if a Provider is missing at the use site, the empty object leaks through and produces runtime bugs. Pattern 2 is almost always safer.

Sending state and dispatch together — split into two Contexts #

When passing both state and a setter through Context, splitting state and dispatch into two Contexts reduces re-render cost. Components that only use dispatch won’t re-render when state changes.

state Context + dispatch Context
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('CounterProvider 안에서만 사용');
  return v;
}

export function useCounterDispatch() {
  const v = useContext(DispatchContext);
  if (v === null) throw new Error('CounterProvider 안에서만 사용');
  return v;
}

Components that only use useCounterDispatch won’t re-render when count changes. Possibly over-optimization for a small app, but if Context is carrying frequent state, it’s a pattern worth considering.

Generic components — components that accept any data #

Components like list, select, and table naturally want to “take any data and render it.” Just like generics on functions, you can use generics on components.

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 trick is function List<T>(props: ListProps<T>)place generic parameters after the function keyword. Callers infer T automatically from the type of items.

Note: In a .tsx file, an arrow function + generics can be ambiguous because <T> might be parsed as a JSX tag. So generic components almost always use the function declaration form.

Generic components + constraints (extends) #

Constraining T to a shape lets you use those fields in the component body.

Constraint — require 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>   // can use item.id
      ))}
    </ul>
  );
}

// Callers don't need keyOf
<List items={todos} renderItem={(t) => <span>{t.text}</span>} />

This shortens the call site, but List now requires id: string. Both have their place — the keyOf function for flexibility; the constraint when safety and short calls matter.

Polymorphic components — switching the tag with an as prop #

You often build a component with the same design that renders as <button> here, <a> there, and <Link> elsewhere. This is called a polymorphic component, and typing it properly takes some work.

Start with the simplest form.

Polymorphic component — basic form
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>기본 div</Box>
<Box as="a" href="/about">링크처럼</Box>
<Box as="button" onClick={() => {}}>버튼처럼</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'> — removes ones that overlap with our own props.
  4. Default generic E = 'div' — when you only write <Box>, the type is div.

With this one pattern, href autocompletes when as="a", onClick autocompletes when as="button" — all accurately.

Use polymorphic components only when really needed #

This pattern is powerful but the types get complex fast and the editor’s autocomplete can get heavy. It’s valuable for design-system libraries, but for ordinary app code building two components like Button/LinkButton is often more readable than as. Use it with the trade-offs in mind.

Generic hooks — just a peek #

The same generic pattern applies to hooks. A common example is “a hook holding API responses,” covered in earnest in the next post (#6 Fetch and API response typing). 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 most dangerous part of this code is r.json() as Promise<T>. We never verified that the server actually returned shape T. The next post covers how to fill that gap safely with zod.

Wrap-up #

This post covered:

  • The default for Context initial values is null + helper pattern
  • Splitting state and dispatch into two Contexts reduces re-renders
  • Generic components use function List<T>(...). Arrow functions clash with JSX
  • For shorter calls, add a constraint like T extends WithId
  • Polymorphic components use as prop + ComponentPropsWithoutRef<E>. Powerful but costly

In the next post (#6 Fetch and API response typing), the last in the series, we cover how to safely handle data from external APIs in TypeScript — combining a generic fetcher and zod runtime validation.

X