TypeScript + React in Practice #5: Context and generic components
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.”
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.
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.
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.
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
.tsxfile, an arrow function + generics can be ambiguous because<T>might be parsed as a JSX tag. So generic components almost always use thefunctiondeclaration form.
Generic components + constraints (extends) #
Constraining T to a shape lets you use those fields in the component body.
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.
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:
E extends ElementType— E is an HTML tag name ('div','a', …) or a component type.ComponentPropsWithoutRef<E>— pulls in every prop of that tag/component.Omit<..., 'as' | 'children'>— removes ones that overlap with our own props.- 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:
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
asprop +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.