React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

typescriptreactgenericscomponentshooksconstraints

Generics in React

Recipe

Build reusable, type-safe React components and hooks using TypeScript generics. Create components that work with any data type while preserving full type information for consumers.

Working Example

// Generic list component
type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string;
};
 
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}
 
// Usage - TypeScript infers T as User
type User = { id: string; name: string; email: string };
 
const users: User[] = [
  { id: "1", name: "Alice", email: "alice@example.com" },
  { id: "2", name: "Bob", email: "bob@example.com" },
];
 
<List
  items={users}
  keyExtractor={(user) => user.id}       // user is typed as User
  renderItem={(user) => <span>{user.name}</span>}  // user is typed as User
/>
// Generic custom hook
function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === "undefined") return initialValue;
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });
 
  const setValue = (value: T | ((prev: T) => T)) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    window.localStorage.setItem(key, JSON.stringify(valueToStore));
  };
 
  return [storedValue, setValue] as const;
}
 
// Usage - T is inferred as { theme: string; fontSize: number }
const [settings, setSettings] = useLocalStorage("settings", {
  theme: "dark",
  fontSize: 14,
});

Deep Dive

How It Works

  • Generic components use a type parameter (e.g., <T>) that acts as a placeholder. When the component is used, TypeScript infers T from the props you pass.
  • The syntax function List<T>({ items }: ListProps<T>) declares a generic function component. The <T> must come before the parameter list.
  • TypeScript infers the generic from the first prop that uses it. In <List items={users} ...>, it sees items is User[], so T becomes User.
  • Generic hooks follow the same pattern as generic functions. The type parameter flows through the return type, giving callers full type inference.

Variations

Constrained generics (requiring a shape):

type HasId = { id: string };
 
type TableProps<T extends HasId> = {
  data: T[];
  columns: (keyof T)[];
};
 
function Table<T extends HasId>({ data, columns }: TableProps<T>) {
  return (
    <table>
      <thead>
        <tr>{columns.map((col) => <th key={String(col)}>{String(col)}</th>)}</tr>
      </thead>
      <tbody>
        {data.map((row) => (
          <tr key={row.id}>
            {columns.map((col) => <td key={String(col)}>{String(row[col])}</td>)}
          </tr>
        ))}
      </tbody>
    </table>
  );
}
 
// Usage - T must have an id field
<Table data={users} columns={["name", "email"]} />

Generic select component:

type SelectProps<T> = {
  options: T[];
  value: T;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
  getValue: (option: T) => string;
};
 
function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) {
  return (
    <select
      value={getValue(value)}
      onChange={(e) => {
        const selected = options.find((opt) => getValue(opt) === e.target.value);
        if (selected) onChange(selected);
      }}
    >
      {options.map((opt) => (
        <option key={getValue(opt)} value={getValue(opt)}>
          {getLabel(opt)}
        </option>
      ))}
    </select>
  );
}

Multiple type parameters:

function usePairedState<A, B>(initialA: A, initialB: B) {
  const [a, setA] = useState<A>(initialA);
  const [b, setB] = useState<B>(initialB);
  return { a, setA, b, setB };
}

TypeScript Notes

  • In .tsx files, <T> in arrow functions can be confused with JSX. Use <T,> (trailing comma) or <T extends unknown> to disambiguate: const List = <T,>({ items }: ListProps<T>) => ....
  • extends in generics acts as a constraint, not inheritance. T extends HasId means "T must have at least the properties of HasId."
  • keyof T gives you a union of all property names of T. Combined with generics, it enables type-safe property access.

Gotchas

  • You cannot use React.FC with generic components. Write them as regular function declarations: function List<T>(props: ListProps<T>).
  • Arrow function generics in .tsx files need the trailing comma trick: <T,> instead of <T>. Without it, the parser thinks <T> is a JSX tag.
  • Over-constraining generics makes components less reusable. Only constrain when you actually access specific properties.
  • Generic inference can fail with complex prop combinations. In those cases, provide the type explicitly: <List<User> items={users} ...>.

Alternatives

ApproachProsCons
Generic componentsFull type inference, reusableMore complex signatures
Union prop typesSimpler to readMust enumerate all supported types
any propsMaximum flexibilityNo type safety
Render props with genericsComposable, type-safeVerbose nesting
HOC with genericsReusable logic wrapperComplex type forwarding

Real-World Example

From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).

// Production example: Generic SSE streaming hook for multiple tool types
// File: src/hooks/use-builder-generator.ts
export function useBuilderGenerator<
  TSections = Record<string, unknown>,
>(options: UseBuilderGeneratorOptions) {
  const { toolId, toolSlug, maxGenerations = 3, onSectionReceived } = options;
 
  const [streamState, setStreamState] = useState<
    BuilderStreamState<TSections>
  >(INITIAL_STREAM_STATE as BuilderStreamState<TSections>);
  const abortControllerRef = useRef<AbortController | null>(null);
 
  const handleSseEvent = useCallback(
    (event: string, data: string) => {
      try {
        if (event === 'error') {
          setStreamState((prev) => ({ ...prev, error: JSON.parse(data).error || 'Generation failed.' }));
          return;
        }
        if (event === 'done') {
          setStreamState((prev) => ({ ...prev, streamingSection: null }));
          return;
        }
        // Tool-specific section parsing via callback injection
        const parsed = onSectionReceived(event, data);
        if (parsed) {
          setStreamState((prev) => ({
            ...prev,
            sections: { ...prev.sections, ...parsed } as TSections,
            streamingSection: event,
          }));
        }
      } catch { /* ignore individual event parse errors */ }
    },
    [onSectionReceived]
  );
 
  const generate = useCallback(async (inputs: Record<string, unknown>, model: string) => {
    abortControllerRef.current?.abort();
    const controller = new AbortController();
    abortControllerRef.current = controller;
 
    setStreamState({ ...(INITIAL_STREAM_STATE as BuilderStreamState<TSections>), isStreaming: true });
 
    const csrfToken = await getCsrfToken();
    const response = await fetch(`/api/tools/${toolSlug}/generate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
      signal: controller.signal,
      body: JSON.stringify({ inputs, model }),
    });
    // ... SSE stream parsing with handleSseEvent
  }, [toolId, toolSlug, handleSseEvent]);
 
  const canGenerate = !streamState.isStreaming && streamState.generationCount < maxGenerations;
 
  return { streamState, generate, abort: () => abortControllerRef.current?.abort(), canGenerate };
}

What this demonstrates in production:

  • TSections generic parameter lets each tool define its own section shape while sharing the streaming logic
  • The onSectionReceived callback is the injection point. Each tool provides its own parser, but the streaming, state management, abort handling, and CSRF logic are shared
  • BuilderStreamState<TSections> carries the generic through the state type so streamState.sections is properly typed per tool
  • abortControllerRef enables cancellation of in-flight requests. Previous requests are aborted when a new generation starts
  • maxGenerations limits how many times a user can generate per session (cost control)
  • One hook implementation serves Network Builder, Object Storage Builder, SQL Database Builder, and NoSQL Builder. Only the section parsing differs

FAQs

How does TypeScript infer the generic type parameter in a component?
  • TypeScript infers T from the first prop that uses the type parameter.
  • In <List items={users}>, it sees items is User[], so T becomes User.
  • All other props using T are then typed as User automatically.
Gotcha: Why can't you use React.FC with generic components?
  • React.FC<Props> does not support a type parameter on the component itself.
  • You must use a regular function declaration: function List<T>(props: ListProps<T>).
  • This is a fundamental limitation of the React.FC type signature.
What is the trailing comma trick for arrow function generics in .tsx files?
// Without trailing comma: parser thinks <T> is a JSX tag
// const List = <T>(props: ListProps<T>) => ...  // ERROR
 
// With trailing comma: disambiguates from JSX
const List = <T,>(props: ListProps<T>) => { ... };
 
// Alternative: use extends
const List = <T extends unknown>(props: ListProps<T>) => { ... };
What does extends mean in a generic constraint?
  • It acts as a constraint, not inheritance. T extends HasId means "T must have at least the properties of HasId."
  • You can then safely access HasId properties on values of type T.
  • Over-constraining makes components less reusable.
How do generic custom hooks work?
function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const item = localStorage.getItem(key);
    return item ? (JSON.parse(item) as T) : initialValue;
  });
  return [value, setValue] as const;
}
  • The type parameter T flows through the return type.
  • Callers get full type inference from the initial value.
What does keyof T do when combined with generics?
  • keyof T gives a union of all property names of T.
  • Combined with generics, it enables type-safe property access (e.g., column names for a table).
  • Example: columns: (keyof T)[] restricts column values to actual properties of T.
When should you provide the generic type explicitly vs relying on inference?
  • Rely on inference for most cases -- it reduces boilerplate.
  • Provide it explicitly when inference fails with complex prop combinations: <List<User> items={users} ...>.
  • Also useful when the initial value does not capture the full type (e.g., empty arrays).
Gotcha: What happens if you over-constrain a generic?
  • The component becomes less reusable because fewer types satisfy the constraint.
  • Only constrain when you actually access specific properties inside the component.
  • A <T> with no constraint accepts any type, maximizing flexibility.
Can you have multiple type parameters in a generic component or hook?
function usePairedState<A, B>(initialA: A, initialB: B) {
  const [a, setA] = useState<A>(initialA);
  const [b, setB] = useState<B>(initialB);
  return { a, setA, b, setB };
}
  • Yes. Each type parameter is inferred independently from the corresponding argument.
How does as const help with generic hook return types?
  • Without as const, the return type of [value, setValue] is inferred as (T | SetStateAction<T>)[].
  • With as const, it becomes a readonly tuple [T, Dispatch<SetStateAction<T>>].
  • This preserves the position-based types for destructuring.