Front End Lesson 8 – UI Architecture Patterns | Dataplexa
Front End Engineering · Lesson 8

UI Architecture Patterns

Examine the structural patterns that keep large front end codebases organised, testable, and maintainable as teams and feature counts multiply — and learn to recognise when each pattern earns its complexity cost.

The Codebase That Grew Without a Plan

Linear — the project management tool that became an industry benchmark for fast, polished software — published a technical retrospective describing a moment in their early growth where their front end codebase had accumulated what they called "architectural debt at the structural level." Individual components were clean. Tests passed. But the relationships between components — who owned what state, which files knew about which other files, where business logic lived — had grown organically into a structure nobody had explicitly chosen.

The symptom was specific: adding a new feature required touching eight files across five directories, with no obvious place for any of the new code. Engineers spent more time deciding where to put things than writing them. That friction is not a productivity problem — it's an architecture problem. And it almost always has the same root cause: the team built components without patterns, and patterns without principles, until the codebase became a collection of local decisions that made global sense to nobody.

UI architecture patterns exist to prevent that specific failure. Not by making the codebase rigid, but by making it legible — so that any engineer can navigate it, any feature can find its place, and any change can be made with confidence about what it will and won't affect.

Container / Presenter — Separating What From How

The most widely-taught UI architecture pattern is also the most misunderstood. Dan Abramov introduced the Container/Presenter separation (also called Smart/Dumb or Stateful/Stateless) in a 2015 Medium article that was read by nearly every React developer of that era. He later wrote a follow-up partially walking it back — not because the idea was wrong, but because teams were applying it mechanically, splitting every component into two regardless of whether the split added clarity.

The core idea is sound and worth understanding. A container component is responsible for what data is shown — it fetches data, manages state, handles side effects, and passes results down as props. A presenter component is responsible for how that data looks — it accepts props and renders UI, with no knowledge of where those props came from. The presenter is a pure function of its inputs. It can be tested by passing any props — no mocking, no store setup, no API.

Container Component

Knows about data sources, APIs, and stores. Fetches, transforms, and manages state. Often contains little or no JSX — it exists to wire data to presenters. Examples at PixelForge: CollaboratorsContainer, ProjectListContainer.

Tests require: mock API, mock store, async handling.

Presenter Component

Knows only about props. No API calls. No store access. Renders exactly what it receives. Fully testable by passing props directly. Fully previewable in Storybook without any infrastructure. Examples: CollaboratorsList, ProjectCard.

Tests require: just props. Fast, isolated, deterministic.

When to Split

Split when a component mixes data-fetching with rendering in a way that makes either responsibility hard to test or reuse. If the presenter logic is trivial (three lines of JSX), the split adds noise without value. If the rendering is complex and data-fetching is entangled with it, the split is worth the extra file.

Modern Alternative: Custom Hooks

React hooks allow the "container" logic — data fetching, state management — to be extracted into a custom hook (useProjectList) and called from within the same component file. The data logic and rendering stay co-located but remain separately testable. This is how most modern React codebases handle the separation without a second file.

The PixelForge Frontend team's convention: container components live in a /containers subdirectory alongside their presenter siblings. But they only create a container when the data logic is complex enough to warrant isolation — simple data-fetching lives in a custom hook inside the component file. The rule is not "always split" — it's "split when the complexity earns it."

The Hooks Revolution and What It Changed

Before React hooks (pre-2019), the container/presenter split was the only clean way to separate stateful logic from rendering. You needed a class component to manage state, and a functional component to render. Hooks collapsed this dichotomy. A custom hook like useCollaborators(projectId) encapsulates all the data-fetching and state management logic, returns clean data and loading states, and can be called from any functional component. The component stays clean. The hook is independently testable. And there's no second file required. Container components still have a place — particularly in codebases with complex Redux middleware — but custom hooks have made the pattern far less necessary for everyday data fetching.

Compound Components — Flexible APIs Without Prop Explosion

Every component library eventually produces a component so complex that a flat prop API can't express all its valid configurations without becoming unwieldy. A Select dropdown with grouped options, icons, custom option rendering, multi-select, clearable, and searchable behaviour would require dozens of props if designed naively — and every combination of those props creates a new implicit behaviour the author must test and document.

Compound components solve this by distributing the API across multiple cooperating components that share implicit state through React context. Instead of a single <Select options={...} groups={...} renderOption={...}>, you get:

Approach API Style Consumer Flexibility Best For
Flat Prop API <Select options renderOption groups /> Low — only what the author anticipated Simple components with 2–4 configuration points
Compound Components <Select><Select.Group><Select.Option /> High — consumers compose the internals they need Complex components with variable internal layout
Render Props <Select renderOption={fn} /> Medium — one specific slot is customisable When a single internal part needs consumer control

The compound component pattern uses React context internally to share state between the parent and its sub-components — without exposing that context to consumers. The <Select> root manages which option is selected. <Select.Option> reads the selected state from context and renders the appropriate visual. The consumer never touches the context — they just compose the sub-components they need in the order they want.

Radix UI's entire component library is built on the compound component pattern. A DropdownMenu is composed from DropdownMenu.Root, DropdownMenu.Trigger, DropdownMenu.Content, and DropdownMenu.Item. Each is independently styleable. The shared behaviour — keyboard navigation, focus management, open/close state — is handled by the Root, invisibly. The PixelForge Design Systems team uses this exact pattern for their ContextMenu, Tabs, and Accordion components — all built on Radix primitives with PixelForge token-based styles applied on top.

Feature-Sliced Design — Organising by What, Not How

Most front end projects organise their files by technical type: a /components folder, a /hooks folder, a /utils folder, a /store folder. This feels natural at the start. At 500 files it becomes a liability — the /components folder contains 200 files with no obvious relationship to each other, and finding everything related to the "Comments" feature requires hunting across five directories.

Feature-Sliced Design (FSD) is an architectural methodology that inverts this. Files are primarily organised by business domain — by what they do in the product — not by what type of code they contain. A slice called canvas contains everything related to the canvas feature: its components, its hooks, its store slice, its API calls, its types, and its tests. Everything about canvas is together. Deleting the canvas feature means deleting one directory.

Feature-Sliced Design — Layer Hierarchy (high to low)

1
app/ — Application initialisation. Router setup, global providers, top-level error boundaries. Nothing in app/ should know about specific features — it only wires them together. At PixelForge: theme providers, auth guards, React Query client setup.
2
pages/ — Route-level components. Each page is thin: it imports features and entities, arranges them into a layout, and wires them to the router. At PixelForge: EditorPage.tsx, DashboardPage.tsx.
3
widgets/ — Self-sufficient UI sections composed from features and entities. A widget is a meaningful block of product functionality that appears in multiple page contexts. At PixelForge: CanvasToolbar, ProjectSidebar.
4
features/ — User interaction logic. Each feature slice handles a specific user action or capability. At PixelForge: add-collaborator/, create-project/, export-canvas/. Each contains its own component, hook, API call, and types.
5
entities/ — Business domain models and their immediate UI. At PixelForge: project/, user/, canvas/, comment/. Each entity slice contains the TypeScript type, the API query, and the core display component for that domain object.
6
shared/ — Framework-agnostic utilities, design system components, API clients, and configuration that has no business domain. At PixelForge: shared/ui/ (base components), shared/api/ (HTTP client), shared/lib/ (date formatting, string utilities).

The critical rule in FSD is unidirectional dependencies: layers can only import from layers below them, never above. A feature can import from entities and shared. A page can import from widgets, features, and entities. Nothing in shared/ can import from features/. This constraint eliminates circular dependencies — the class of import error that is genuinely difficult to debug and almost always indicates a structural problem in the codebase.

FSD vs Technical Folder Structure — The Same Codebase, Two Experiences

A senior engineer joining PixelForge on day one navigates to features/add-collaborator/ and immediately sees the component, the API call, the hook, the types, and the tests — everything needed to understand and modify that feature. Under a technical folder structure, that same engineer navigates to components/, then hooks/, then api/, then types/, reconstructing the feature from scattered pieces. At 500 files that reconstruction takes minutes. At 5,000 files it takes the better part of a morning — every single time.

Event-Driven UI — Decoupling Components That Don't Know About Each Other

State management and props cover most inter-component communication — but there's a class of communication that neither handles cleanly. Two components that need to react to the same event but have no parent-child relationship, no shared context, and no reason to be wired directly together. In a monolith this might be two widgets in completely different parts of the tree that both need to respond when a user's session expires.

The Observer pattern — or event bus — solves this. A centralised event emitter allows any part of the application to publish events, and any other part to subscribe to them, without either side knowing the other exists. The publisher doesn't know who's listening. The subscriber doesn't know who published. They're decoupled by the event contract alone.

Direct Component Communication

A session expiry event in the AuthProvider needs to notify the Toast component, the CanvasSaveQueue, and the CollaboratorPresence module. To do this directly: AuthProvider must know about and import all three. Every new subscriber adds another import to AuthProvider. Authentication logic becomes coupled to notification logic, canvas logic, and collaboration logic.

Coupling cost: AuthProvider grows with every new subscriber.

Event Bus Communication

AuthProvider publishes session:expired to the event bus. Toast subscribes and shows a warning. CanvasSaveQueue subscribes and flushes pending saves. CollaboratorPresence subscribes and disconnects from the WebSocket. None of these know about each other. AuthProvider doesn't know any of them exist.

Coupling cost: zero. New subscribers add zero complexity to the publisher.

The PixelForge Platform team implemented an internal event bus using the browser's native CustomEvent API and a thin typed wrapper that enforces event name and payload contracts in TypeScript. Their event catalogue — a TypeScript union type listing every valid event name and its payload shape — serves as the application's communication contract. Any module can publish or subscribe to typed events without importing anything except the event bus singleton and the shared type definitions.

When Event Buses Become a Problem

The decoupling that makes event buses powerful also makes them dangerous. An event with three subscribers is easy to reason about. An event with fourteen subscribers — some added over eighteen months by engineers who have since left — is a debugging nightmare. When a canvas:element-selected event causes an unexpected side effect, finding which subscriber is responsible requires knowing the full list of subscribers at runtime. Use event buses for cross-cutting concerns (auth, theming, global notifications) where the publisher genuinely should not know its subscribers. For component-to-component communication within a feature boundary, props and callbacks remain the clearer, more traceable choice.

Render Strategies as Architecture — Islands, Streaming, and Partial Hydration

The rendering model choices from Lesson 3 — CSR, SSR, SSG, ISR — operate at the page level. But as front end architecture has matured, a new set of patterns has emerged that operate at the component level, allowing different parts of the same page to use different render strategies. These patterns are among the most significant architectural advances in front end engineering in the last five years.

Astro popularised the Islands Architecture: a page is predominantly static HTML (fast, no JavaScript cost) with isolated "islands" of interactivity hydrated independently. Each island is a separate React, Vue, or Svelte component that loads and hydrates only when needed — the rest of the page has no JavaScript overhead at all. This is how Astro powers documentation sites and content-heavy pages with near-zero JavaScript bundle costs while still supporting interactive components where the product requires them.

Pattern What It Does JS Cost PixelForge Application
Islands Architecture Static HTML page with isolated interactive components that hydrate independently Minimal — only islands pay JS cost Marketing site — static content, interactive pricing calculator island
React Server Components Components that render on the server and stream HTML — they can access databases directly and ship zero client JS Zero — server components ship no JS bundle Project list page — data-heavy read views where interactivity is minimal
Streaming SSR Server sends HTML in chunks as it becomes ready — above-the-fold content arrives before slow data-fetching completes below it Normal SSR cost, better perceived performance Dashboard — header and nav stream instantly, data-heavy widgets stream as queries resolve
Resumability (Qwik) Server serialises the entire application state including event listeners — browser "resumes" from where the server left off with no hydration step Near-zero — no hydration JS executed on load Not yet adopted — on Platform team's 2025 evaluation list

React Server Components — introduced in Next.js 13 and stabilised in Next.js 14 — represent the most significant change to React's component model since hooks. A Server Component renders on the server, can directly await database queries and file system reads, and ships pure HTML to the client with zero JavaScript in the bundle for that component. Client Components — marked with 'use client' — work exactly as before and can be nested inside Server Components. The architecture allows you to compose server-rendered data-fetching with client-rendered interactivity at the component level rather than the page level.

The PixelForge Platform team's migration to Next.js App Router brought Server Components to their project dashboard feature. The ProjectListPage went from a Client Component that fetched data via React Query (shipping ~18KB of query infrastructure to the client) to a Server Component that awaits the database directly and ships zero client JavaScript for the data-fetching layer. Time to First Byte dropped 180ms. The React Query client was removed from that route entirely — 18.2KB removed from the route's JS budget. The interactive parts — filters, sorting, search — remained as Client Components inside the Server Component tree.

Choosing an Architecture — The Questions That Matter

Every architecture pattern in this lesson has a place. None of them should be adopted wholesale without asking whether their complexity cost is justified by the problem they solve. A five-person team building a startup's first product does not need Feature-Sliced Design. A team of forty with five independent product squads probably does. An event bus is powerful for cross-cutting concerns and dangerous for everything else.

The question is never "which pattern is best?" — it's "which problems do I currently have, which of these patterns solves them, and what new complexity does adopting the pattern introduce?" Architecture is a trade-off, not a truth. The best front end architects are not the ones who know the most patterns. They're the ones who introduce the right pattern at the right moment and resist introducing it before the problem it solves is actually present.

The PixelForge Architecture Evaluation Framework

1
Name the specific pain — "Finding all files related to a feature requires searching five directories" is specific. "Our codebase is hard to navigate" is not. Patterns that solve vague problems introduce complexity without solving anything.
2
Quantify the cost of not solving it — "This costs engineers 30 minutes per new feature task to navigate" makes the ROI of an architectural change calculable. If the pain is unquantifiable, the pattern is probably premature.
3
Identify the simplest solution first — Would a naming convention fix it? A new directory? A shared utility? The simplest solution that solves the problem is always the correct starting point. Patterns are for problems that simpler solutions cannot solve.
4
Prototype on one feature before applying everywhere — Apply the pattern to one feature slice for two weeks. Does it actually solve the named pain? Does it introduce new friction that wasn't anticipated? Architecture decisions are expensive to undo — validate on a small surface before committing the whole codebase.
5
Write the ADR before, not after — An Architecture Decision Record written before implementation forces clarity about the problem, the options considered, and the trade-offs accepted. Written after, it's documentation. Written before, it's thinking. The PixelForge Platform team requires an ADR for any change that affects more than one feature slice or introduces a new dependency pattern.

The patterns in this lesson — Container/Presenter, Compound Components, Feature-Sliced Design, Event Bus, Islands/Server Components — are not a menu to order from. They're tools with specific appropriate uses. Knowing them all is the prerequisite. Knowing when each one earns its place is the skill. And that skill develops through the practice of naming pains specifically, solving them with the minimum complexity required, and resisting the architecture astronaut instinct to solve tomorrow's problems before today's are understood.

Quiz

1. Adding a new PixelForge feature currently requires touching files in /components, /hooks, /api, /store, and /types — five separate directories. Each change takes engineers 30+ minutes just to locate the right files. Which architectural pattern directly solves this navigation problem?

2. PixelForge's Design Systems team is building a Select component that needs to support grouped options, icons, multi-select, clearable, and searchable modes. A flat prop API would require 14+ props. Which pattern gives consumers flexibility without an unwieldy API?

3. PixelForge's project list page uses React Query to fetch project data client-side, shipping 18KB of query infrastructure to every user. The page has minimal interactivity — just read-only project cards. Which render strategy eliminates the client-side data-fetching cost entirely?

Up Next
State Management Fundamentals
PixelForge's Frontend team digs into how state actually moves through an application — what it is, where it can live, how it triggers re-renders, and why the wrong state model compounds into the most expensive kind of technical debt to unwind.