Skip to content

Typed React and Application Structure

TypeScript can check the data flowing through React components, hooks, events, and context. The best React types usually describe domain state and component responsibilities; they do not attempt to reproduce the framework's internals.

Props are ordinary object types

tsx
type TransactionRowProps = {
  transaction: Transaction;
  selected?: boolean;
  onSelect: (id: TransactionId) => void;
};

export function TransactionRow({
  transaction,
  selected = false,
  onSelect,
}: TransactionRowProps) {
  return (
    <button
      aria-pressed={selected}
      onClick={() => onSelect(transaction.id)}
    >
      {transaction.description}
    </button>
  );
}

The return type is normally inferred. Avoid automatically typing every component as React.FC; an ordinary function is clearer and modern TypeScript already checks its JSX use.

Choose the children type based on what the component accepts:

tsx
import type { ReactNode } from "react";

type PanelProps = {
  title: string;
  children: ReactNode;
};

ReactNode represents broadly renderable content. ReactElement is narrower and useful when an API specifically requires an element object. A render callback needs a function type:

tsx
type ListProps<Item> = {
  items: readonly Item[];
  getKey: (item: Item) => string;
  renderItem: (item: Item) => ReactNode;
};

Generic components preserve relationships without knowing the item type:

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

Let JSX and event context infer specifics

Inline handlers are inferred from the element:

tsx
<input onChange={event => setQuery(event.currentTarget.value)} />

When extracting one, annotate the relevant React event:

tsx
import type { ChangeEvent } from "react";

function handleQueryChange(event: ChangeEvent<HTMLInputElement>) {
  setQuery(event.currentTarget.value);
}

Use currentTarget when you mean the element whose handler is running. target can be a descendant and therefore has a less specific type.

State inference needs a complete starting point

tsx
const [query, setQuery] = useState(""); // inferred string

Nullable or union state often needs an explicit argument:

tsx
const [selected, setSelected] = useState<Transaction | null>(null);

type LoadState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; value: T }
  | { status: "failure"; error: Error };

const [transactions, setTransactions] =
  useState<LoadState<readonly Transaction[]>>({ status: "idle" });

Discriminated state prevents impossible combinations and narrows naturally in render branches.

Refs for DOM elements include the initial null state:

tsx
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current?.focus();

Writing null! suppresses a meaningful lifecycle fact. Optional access or an explicit guard is usually honest.

Context should express its missing-provider behavior

tsx
type SessionContextValue = {
  user: User;
  signOut: () => Promise<void>;
};

const SessionContext = createContext<SessionContextValue | null>(null);

function useSession(): SessionContextValue {
  const value = useContext(SessionContext);
  if (value === null) {
    throw new Error("useSession must be used within SessionProvider");
  }
  return value;
}

A fabricated default value hides a missing provider. A small custom hook performs one runtime check and gives consumers a non-null type.

Organize around features and boundaries

A modest React application might grow toward:

text
src/
  app/
    App.tsx
    routes.tsx
  transactions/
    api.ts
    model.ts
    TransactionList.tsx
    TransactionPage.tsx
  shared/
    Button.tsx
    formatCurrency.ts
  main.tsx

This is an example, not a required taxonomy. Feature modules keep domain model, server adapter, and components discoverable together. shared should contain genuinely shared concepts, not everything whose home is undecided.

Keep transport shapes at the network boundary:

ts
type TransactionResponse = {
  id: unknown;
  amount: unknown;
  description: unknown;
};

export async function fetchTransactions(signal?: AbortSignal): Promise<Transaction[]> {
  const response = await fetch("/api/transactions", { signal });
  if (!response.ok) throw new HttpError(response.status);
  return parseTransactions(await response.json());
}

Components consume Transaction[], not loosely typed HTTP records. This keeps rendering focused on UI decisions and gives other entry points access to the same validated domain data.

Avoid a component abstraction merely because two snippets look similar. Extract when they share behavior, accessibility requirements, visual policy, or a stable semantic role. A highly configurable “universal component” can be harder to use than two direct components.

Framework boundaries remain runtime boundaries

Route parameters, form data, loader results, browser storage, and server-rendered payloads still require parsing. Server and client TypeScript types do not prove that deployments use matching versions or that an attacker sent valid input.

Frameworks may introduce server components, loaders, actions, generated routes, or directives interpreted by a compiler. Ask the same layering questions from the opening article:

  • Is this JavaScript/TypeScript syntax or framework convention?
  • Does the code run in the browser, on a server, or during the build?
  • Which values cross a serialization boundary?
  • Which tool supplies the generated types?

TSX is most valuable when it exposes these contracts clearly: component inputs, explicit state alternatives, validated data at edges, and narrowly owned effects. It cannot make an unclear React state model clear on its own.

Further reference