React + TypeScript: Patterns That Actually Matter in Production
Why TypeScript Over Plain JavaScript?
After working on several large React codebases in both JavaScript and TypeScript, the verdict is clear: TypeScript catches a category of bugs that unit tests never will. Especially in large teams, the type system is the fastest way to communicate intent and prevent regressions.
Typing API Responses with RTK Query
When using Redux Toolkit's RTK Query, always type your endpoints:
interface Device {
id: string;
hostname: string;
status: "online" | "offline" | "pending";
lastSeen: string;
tenantId: string;
}
const deviceApi = createApi({
reducerPath: "deviceApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
endpoints: (builder) => ({
getDevices: builder.query<Device[], string>({
query: (tenantId) => `/devices?tenant_id=${tenantId}`,
}),
updateDevice: builder.mutation<Device, Partial<Device> & { id: string }>({
query: ({ id, ...body }) => ({
url: `/devices/${id}`,
method: "PATCH",
body,
}),
}),
}),
});
Generic Components with TypeScript
Generic components let you build reusable UI with full type safety:
interface TableProps<T> {
data: T[];
columns: Column<T>[];
onRowClick?: (row: T) => void;
loading?: boolean;
}
function DataTable<T extends { id: string }>({
data,
columns,
onRowClick,
loading = false,
}: TableProps<T>) {
// Component implementation
}
Discriminated Unions for State Management
Use discriminated unions to model component states explicitly:
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function useDeviceSync(tenantId: string): AsyncState<Device[]> {
const { data, isLoading, error } = useGetDevicesQuery(tenantId);
if (isLoading) return { status: "loading" };
if (error) return { status: "error", error: "Failed to load devices" };
if (data) return { status: "success", data };
return { status: "idle" };
}
Component Props Patterns
Prefer explicit prop interfaces over inline types:
// ✅ Good — named interface, easy to reuse and extend
interface DeviceCardProps {
device: Device;
onAction: (action: DeviceAction) => void;
compact?: boolean;
}
// ❌ Avoid — inline types for complex props
function DeviceCard({
device,
onAction,
}: {
device: Device;
onAction: (action: DeviceAction) => void;
}) {}
Strict Mode Configuration
Always enable strict TypeScript settings:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
Conclusion
TypeScript's value compounds over time. The initial friction of writing types pays off exponentially in a 6-month-old codebase where you've forgotten what a function returns, or in a PR review where the type error catches a bug before it reaches production.
Start strict from day one — retrofitting TypeScript into a large JavaScript codebase is painful.