Appearance
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>;
}Mutually exclusive props should be modeled as alternatives rather than a bag of optionals:
tsx
type ActionProps =
| { kind: "link"; href: string; onClick?: never; children: ReactNode }
| { kind: "button"; href?: never; onClick: () => void; children: ReactNode };
function Action(props: ActionProps) {
if (props.kind === "link") {
return <a href={props.href}>{props.children}</a>;
}
return <button onClick={props.onClick}>{props.children}</button>;
}The discriminant prevents callers from supplying both href and onClick or neither. It also gives the implementation a branch where the correct element and props are known. Use this pattern when alternatives have different semantics, not merely to show type-system technique.
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.
If an event handler awaits, capture any needed element value before the asynchronous boundary. The event describes a dispatch in progress; keeping an element reference around is usually less clear than retaining the string, file, or identifier the operation needs.
State inference needs a complete starting point
tsx
const [query, setQuery] = useState(""); // inferred stringNullable 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.tsxThis 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?
Rendering modes move work across boundaries
Client rendering sends a JavaScript application that creates the initial interface in the browser. Server rendering produces initial HTML on request; the browser may then hydrate that markup so React can attach event behavior. Static generation produces HTML earlier, during a build or revalidation step. These modes can coexist route by route.
Hydration assumes the server and client produce compatible initial trees. Reading window, current time, random values, or browser storage during an otherwise server-rendered render can produce mismatched markup. Read environment-specific values in the layer that owns them and pass a serializable initial representation across the boundary.
Server components, where supported by a framework, execute only on the server and can keep some data access and dependencies out of the client bundle. Client components own browser state, effects, and event handlers. The boundary is architectural and tool-defined; the "use client" directive is not general ECMAScript syntax.
Data crossing from server-rendered code to browser code must fit the framework's serialization protocol. A class instance, database connection, callback, or secret cannot become safe merely because its TypeScript type appears in both projects. Convert to an explicit transport shape and validate values that cross independently deployed systems.
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.
Optional prompts
Predict: For the ActionProps union, which combinations of kind, href, and onClick are valid, and why use never on the excluded prop?
Answer: A link requires
kind: "link"andhrefwhile forbiddingonClick; a button requireskind: "button"andonClickwhile forbiddinghref. Optionalnevermakes contradictory combinations fail at the call site, while the discriminant narrows the implementation to the appropriate intrinsic element.
Explain: A server and client import the same Transaction type. Why can the client still receive an invalid transaction?
Answer: The type exists only during checking and may describe separately deployed code that is out of version sync. Network payloads can also be malformed or hostile. Serialize an explicit transport shape and validate the received runtime value before constructing the trusted domain model.
Modify: Design props for a result panel so loading cannot coexist with successful data or an error.
Answer: Use a discriminated union such as
{ status: "loading" } | { status: "success"; items: readonly Item[] } | { status: "failure"; error: Error }. Each branch contains only its valid data, and render-time narrowing prevents impossible prop combinations from spreading through the component.