Chapter 4 of 4

Generic Components and Practices

Components that adapt to their data, and the habits worth keeping.

A component that works with any item type - a list, a table, a select - should be generic rather than typed with any.

interface ListProps<T> {
  items: T[];
  getKey: (item: T) => string;
  renderItem: (item: T) => React.ReactNode;
}

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

// T is inferred as User at the call site
<List
  items={users}
  getKey={(user) => user.id}
  renderItem={(user) => <span>{user.name}</span>}
/>

Useful utility types

type UserProps = React.ComponentProps<typeof UserCard>;   // borrow a component's props
type PartialUser = Partial<User>;                          // all fields optional
type UserPreview = Pick<User, "id" | "name">;              // a subset
type WithoutId = Omit<User, "id">;                         // everything but id
type Status = "idle" | "loading" | "error";                // a closed set

Practices worth adopting

  • Turn on strict in tsconfig. Half the value of TypeScript is in the strict null checks.
  • Prefer union types over booleans for state: "idle" | "loading" | "error" makes invalid combinations unrepresentable.
  • Avoid any. Use unknown when a type is genuinely unknown and narrow it before use.
  • Do not annotate what is already inferred - extra annotations go stale.
  • Type the boundaries, not the middle: API responses and component props matter most.
// Booleans allow impossible states
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);   // both true? both false?

// A union does not
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");

Model status as a closed set

Complete nextStatus so it returns 'loading' from 'idle', 'idle' from 'loading', and 'idle' from 'error'.