React TypeScript Architecture Patterns I Use on Every Production Project

The patterns I reach for when building React apps that need to last longer than six months — custom hook contracts, state colocation, strict boundary typing, and the folder structure that doesn't collapse.

Six months after you ship a React app, one of two things is true: the codebase is still navigable, or it’s become a place where changes are scary.

The difference is almost never the framework version or the state management library. It’s whether the architecture was designed to be read by future developers — including yourself — under deadline pressure.

These are the patterns I use. Not because I invented them, but because they’ve held up.


1. Custom Hooks are Contracts, Not Utilities

The most common misuse of custom hooks is treating them as “logic extractors” — pulling code out of components into hooks to make the component file shorter, without thinking about what the hook is supposed to promise.

A custom hook is a contract. It says: give me these inputs, I’ll give you this output, and I’ll handle this specific concern. That contract should be expressible in TypeScript without ambiguity.

// Bad: unclear contract, returns too much, caller has to know internals
function useUserData(id: string) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  // ... fetch logic
  return { user, setUser, loading, error, setError }; // exposes setters
}

// Good: clear contract, owns its state completely
interface UseUserResult {
  user: User | null;
  isLoading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useUser(id: string): UseUserResult {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const fetch = useCallback(async () => {
    setIsLoading(true);
    try {
      const data = await api.getUser(id);
      setUser(data);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
    } finally {
      setIsLoading(false);
    }
  }, [id]);

  useEffect(() => { fetch(); }, [fetch]);

  return { user, isLoading, error, refetch: fetch };
}

The version that doesn’t expose setUser and setError is the better one. Callers can’t accidentally corrupt the state. If they need to update user data, they call refetch. The hook owns its state completely.

Name the return type explicitly with an interface. It forces you to think about the contract before writing the implementation, and it makes the hook self-documenting.


2. State Colocation — Keep State Close to Where It’s Used

The most common architecture mistake in React is putting state too high.

State that only one component uses doesn’t belong in a global store. State that three sibling components share belongs in their closest common ancestor. State that the entire app needs belongs at the root or in a store.

The colocation rule: state lives at the lowest level that satisfies all its consumers.

// Bad: global store for local UI state
// store.ts
export const useAppStore = create((set) => ({
  isModalOpen: false,
  setModalOpen: (v: boolean) => set({ isModalOpen: v }),
  selectedItem: null,
  setSelectedItem: (item: Item) => set({ selectedItem: item }),
  // ... 40 more pieces of state that only one component uses
}));

// Better: local state for local concerns
function ItemList({ items }: { items: Item[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [isModalOpen, setModalOpen] = useState(false);

  return (
    <>
      {items.map(item => (
        <ItemCard
          key={item.id}
          item={item}
          onSelect={() => { setSelectedId(item.id); setModalOpen(true); }}
        />
      ))}
      {isModalOpen && selectedId && (
        <ItemModal id={selectedId} onClose={() => setModalOpen(false)} />
      )}
    </>
  );
}

Global state should hold things that are genuinely global: authentication, user preferences, feature flags, shopping cart. Not “which row in this table is expanded.”

When you put UI state in a global store, every component in the app re-renders when that state changes. When you colocate it, only the subtree that uses it re-renders.


3. Type Your API Boundaries, Not Your Internals

Strict TypeScript is table stakes. But where you enforce strictness matters.

The highest-leverage place to write precise types is at API boundaries — where data enters your app from the outside world.

// api.ts — the boundary where external data enters
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1),
  role: z.enum(['admin', 'member', 'viewer']),
  createdAt: z.string().datetime(),
});

type User = z.infer<typeof UserSchema>;

async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const data = await response.json();
  return UserSchema.parse(data); // throws if API returns unexpected shape
}

Zod (or equivalent runtime validation) at the API boundary means that if the backend ships a breaking change, you get a thrown error with a description of what changed — instead of undefined is not an object three layers deep in your rendering logic.

Inside the app — between components, inside hooks — TypeScript’s static analysis is sufficient. You don’t need runtime validation for data that never left your process.


4. Component Boundaries at Data Ownership

Components should own their data or receive it as props. The problematic middle ground is a component that fetches its own data and accepts props that affect the fetch and renders child components that also fetch data. You end up with waterfalls and unclear ownership.

The pattern I use: separate “container” components (own data fetching) from “presentational” components (only props, no fetching).

// Container: owns data fetching
function UserProfilePage({ userId }: { userId: string }) {
  const { user, isLoading, error } = useUser(userId);

  if (isLoading) return <ProfileSkeleton />;
  if (error) return <ErrorBoundaryFallback error={error} />;
  if (!user) return null;

  return <UserProfile user={user} />;
}

// Presentational: pure, testable, reusable
interface UserProfileProps {
  user: User;
}

function UserProfile({ user }: UserProfileProps) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <RoleBadge role={user.role} />
    </div>
  );
}

UserProfile is trivial to test — pass it a user object, assert the rendered output. UserProfilePage handles the async lifecycle. Testing these separately is much cleaner than testing a component that does both.

This pattern also makes Storybook stories straightforward: stories only need UserProfile (pure props), not UserProfilePage (requires API mocking).


5. Folder Structure That Scales

The folder structure that doesn’t collapse under scale is the one where you can find any file by answering two questions: “what feature is this for?” and “what kind of thing is it?”

src/
  features/
    auth/
      components/
        LoginForm.tsx
        LoginForm.test.tsx
      hooks/
        useAuth.ts
        useAuth.test.ts
      api.ts
      types.ts
      index.ts          ← public interface of this feature
    users/
      components/
      hooks/
      api.ts
      types.ts
      index.ts
  shared/
    components/         ← used by 2+ features
      Button/
        Button.tsx
        Button.test.tsx
        index.ts
    hooks/              ← used by 2+ features
    utils/
    types.ts
  pages/                ← routing layer, thin
    UsersPage.tsx
    AuthPage.tsx
  app/                  ← providers, router, global config
    App.tsx
    providers.tsx

The critical rule: features import from shared, never from each other. If auth imports from users, you have a circular dependency waiting to happen. If two features need to share something, that something belongs in shared.

The index.ts file in each feature is its public interface. Other parts of the app import from features/auth, not from features/auth/components/LoginForm. This means you can refactor internal feature structure without touching imports outside the feature.


6. Error Boundaries at Route Level

Every route should have an ErrorBoundary. If a component deep in a route throws, you want to catch it at the route level — not bubble it to the app root and white-screen the user.

import { ErrorBoundary } from 'react-error-boundary';

function AppRouter() {
  return (
    <Routes>
      <Route
        path="/users"
        element={
          <ErrorBoundary FallbackComponent={RouteErrorFallback}>
            <UsersPage />
          </ErrorBoundary>
        }
      />
    </Routes>
  );
}

function RouteErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div role="alert">
      <p>Something went wrong on this page.</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

In production, replace error.message with a logged error ID and a support link. The error detail shouldn’t be visible to users, but it should be sent to your error tracker (Sentry, Datadog, etc.).


7. Strict TypeScript Config, Enforced From Day One

The tsconfig.json that matters:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

strict: true gets you strictNullChecks, noImplicitAny, and a handful of others. The three additional options catch things strict misses:

  • noUncheckedIndexedAccessarray[0] returns T | undefined, not T. Prevents “cannot read property of undefined” at runtime.
  • exactOptionalPropertyTypes{ foo?: string } won’t accept { foo: undefined }. Precise optional semantics.
  • noImplicitReturns — all code paths in a function must return. Catches missing return in switch branches.

Turn these on at the start of a project. Retrofitting strict TypeScript onto an existing React codebase that started with loose config is one of the more unpleasant experiences in frontend engineering.


These patterns aren’t clever. They’re boring in the good way — they reduce the number of decisions you make while writing code, which leaves more attention for the decisions that actually matter.

If you’re starting a production React project and want the architecture set up correctly from day one, I do that.

☀ Try The Archive