React Frontend Patterns: Building Predictable UIs
Goal: Demystify Reactβs rendering and provide patterns for building testable, maintainable, and performant features using React Hooks. This guide focuses on a mental model applicable even without deep frontend expertise.
Key Concepts:
- React as a Tree: Understanding component structure, data flow rules, and re-rendering.
- State Management: Where state lives (Local vs. Global) in the tree.
- Three-Tier Hook Architecture: Separating data access (Repository), business logic (Service), and UI connection (Controller).
- Vertical Slicing: Organizing code by feature for better colocation.
Core Principles: React is a Tree
View your React app as a Tree governed by two rules:
- Data Flows Down; Communication via Ancestors: Props pass from parent to direct child. Siblings communicate via their nearest common ancestor node.
- Node Re-evaluation Re-evaluates Subtree: When a component re-renders (due to its state, props, parent re-render, or context update), its entire subtree re-renders by default. Managing this cascade is crucial for performance, and where state is located within the tree directly dictates the scope of these re-renders.
From this we also get the general guidelines that pushing state down to the leaf nodes (collocating state) tends to be easier to reason about and helps improve performance.
βββββ
βAppβ
ββ¬βββ
βββββββ½ββββββ
β AuthGuard β
βββββββ¬ββββββ
ββββββββ½βββββββββββββββββββ
β Feature Page β
ββββββββ¬ββββββββββββββββββ¬β
ββββββββ½βββββββ ββββββββ½βββββββ
β Component A β β Component B β
ββββββββ¬βββββββ ββββββββ¬βββββββ
ββββββββ½βββββββ ββββββββ½βββββββ
β Leaf A1 β β Leaf B1 β
βββββββββββββββ βββββββββββββββ
State Management in the Tree
Understanding where state lives helps predict re-renders.
Local State (useState, useReducer)
React manages local state outside the component functionβs execution scope, associating it with the component instance. When setState is called:
- React updates the state value for that specific component instance.
- React re-runs the component function.
- Rule #2 applies: The component and its entire subtree re-render.
The key: State updates trigger re-renders starting at the component holding the state. Therefore, keeping state as close as possible to where itβs needed minimizes the subtree affected by Rule #2.
Conceptually we can think of this as a React β [hooks] β Component. There is always a conceptual parent node that we are reading values and calling callbacks from, useState is no different.
Global State (Stores, Context, URL, Cache)
Global state acts like a shared common ancestor node for all components subscribing to it. This allows you structure your tree in such a way that state changes are isolated only to the components that need them.
[Root]
/ \ +-----------------+
[A] ---- [B] | External State |
/ \ | |(Common Ancestor)|
[A1] [A2] -- [B1] <------ +-----------------+
^ ^
|______| (Components subscribe)
The Tree rules still apply:
- Communication (Rule #1): Components connect to this βancestorβ to read/update state.
- Re-renders (Rule #2): Changes in the subscribed part of the global state trigger re-renders in the consuming component and its subtree.
Effective global state management often uses selectors or specific keys to narrow subscriptions, minimizing the impact of Rule #2.
Relation to Flux Architecture
The React patterns discussed in this guide, especially unidirectional data flow, relate directly to the Flux architecture (Action -> Dispatcher -> Store -> View). Understanding Flux helps explain how state updates propagate predictably in React, even when using modern state libraries.
Flux Principles in React State Management:
-
Unidirectional Data Flow: Flux enforces a strict one-way data path:
- Actions, triggered by user input or events, describe intended state changes.
- A central Dispatcher processes these actions (though often abstracted in modern libraries).
- Stores hold application state and update only in response to actions.
- Views (React components) read data from Stores and re-render when the store data they depend on changes. This flow directly maps to Reactβs principles: data flows down the component tree (Rule #1), and state updates trigger re-renders in subscribed subtrees (Rule #2, with the Store often acting conceptually like a shared ancestor).
-
Centralized State Updates: State management libraries (like Redux, Zustand, Jotai, or
useReducerwith Context) provide various ways to implement Stores and connect components (Views) to them. Techniques include selectors, context propagation, or automatic tracking (e.g., via proxies). Despite these abstractions, a core Flux principle remains: components typically do not directly modify shared state. Instead, they dispatch Actions or call defined functions that encapsulate state mutations. -
Predictable Updates: This separation leads to predictable state transitions. The update cycle, regardless of the specific library, generally follows the Flux pattern:
- An Action initiates a state change request.
- The Store updates its state based on the action.
- The state management system notifies subscribed Views of the change.
- Views re-render using the new state.
Managing state updates through this controlled, unidirectional flow ensures that changes propagate predictably through the React component tree. This makes applications easier to reason about and debug, aligning with the goal of building predictable UIs.
The Problem: Unstructured Complexity
Without structure, components mix concerns (data fetching, UI state, global state), leading to:
- Performance Bottlenecks: Unrelated state changes, especially state held unnecessarily high in the tree, trigger large, unnecessary re-renders (making the impact of Rule #2 far worse than it needs to be).
- Poor Organization: Code becomes hard to read, test, and maintain.
- Reduced Reusability: Logic is tightly coupled to specific UI components.
+----------------------------------------------------+
| Monolithic Component |
|----------------------------------------------------|
| Calls: useQuery(), useState(), useStore(), etc. | <--- Tangled Logic
| (All responsibilities mixed) | <--- Difficult to test/debug
+----------------------------------------------------+
|
V
[Large Subtree Re-renders Frequently] <--- Performance Issues (Rule #2)
Solution Part 1: Three-Tier Hook Architecture
Organize hooks into layers to manage complexity and control re-renders based on our Tree rules.
+---------------------------+ (UI Interaction)
| Component (Controller) |ββββββ Connects UI to Services
+---------------------------+ β Manages UI-specific state
β β
V β
+---------------------------+ <ββββ Consumes Repositories
| Service Hook(s) |ββββββ Implements Business Logic
| (Orchestration) | β Orchestrates *what*, *why*, *when*
+---------------------------+ β
β β
V β
+---------------------------+ <ββββ Handles *how* data is accessed
| Repository Hook(s) |βββββ> [Data Sources: API, Store, etc.]
| (Data Access) | Unaware of business logic
+---------------------------+
-
Repository Hooks:
- Responsibility: How data is fetched/mutated (e.g., API calls, cache interaction, dom interactions, URL state, etc..).
- Key Trait: Unaware of why data is needed; focused solely on data access.
- Impact: Isolates data source specifics. Changes here rarely affect Services or UI.
-
Service Hooks:
- Responsibility: What, why, and when. Orchestrates Repositories, contains business logic, transforms data, manages feature state.
- Key Trait: Composes Repositories; core feature logic lives here.
- Impact: Separates business rules from UI and data fetching. Improves testability. Can be reused across different UI components.
-
Controller Logic (in Components):
- Responsibility: Connect UI elements to Service Hooks, pass data down, and strategically manage state placement. While components consume Service logic, they should handle purely UI-related state (like form input values, toggle states) as low as possible in the tree, ideally only within the components that directly depend on that state.
- Key Trait: Primarily wiring and state delegation. Avoid holding transient UI state that only affects a small, distant part of its subtree.
- Impact: Keeps components focused on presentation and connection. Pushing state down limits the scope of re-renders when that UI state changes, directly optimizing for Rule #2 and improving performance.
This tiered structure, combined with mindful state placement, makes the flow of data and control clearer within the Tree model, localizing state updates and making re-renders more predictable.
Solution Part 2: Vertical Slicing by Feature
Organize the feature by feature (or domain) to improve colocation, rather than by layer type (hooks, components).
- Goal: Each feature should be a self contained subtree.
- Benefit: Isolated dependencies, can be easily moved or re-used in a different context by changing the injected context
How Tree Rules & Patterns Enable Vertical Slicing:
-
React Query Cache (Global Server State Ancestor):
- Acts as a global common ancestor for server state.
- Any component can subscribe directly (
useQuery). - Benefit: Circumvents Rule #1 (strict parent-child data flow) for server data. Deep components can fetch needed data without prop drilling.
- Performance: Optimizes Rule #2 for server data. Cached data is retrieved without forcing ancestor re-renders. Fetching logic (Repositories) stays within the feature slice.
-
Service Hooks + Context (Feature-Scoped Ancestor):
- Manage feature-specific state/logic within the slice using Service Hooks.
- Use React Context to provide stable logic or derived state down the featureβs subtree without prop drilling (Rule #1 applied locally).
- Pattern:
- Feature root component calls Service Hook(s).
- Renders
FeatureContext.Providerwith the memoized service value (containing stable actions or data derived from multiple sources). - Descendant components use
useContext(FeatureContext)to access this stable logic/data.
- Benefit: Decouples leaf components from intermediate structure. This pattern works best when the context provides relatively stable values. Frequently changing, purely UI state is often better handled locally within leaf components (pushed down) rather than passed through context, to avoid unnecessary context consumer re-renders.
- Performance: Precisely controls Rule #2 for the shared service logic within the slice. Context consumers re-render only if the specific part of the memoized context value they use changes.
// More Realistic Example: User Preferences Feature Slice
import React, {
createContext,
useContext,
useState,
useMemo,
useCallback,
ReactNode,
} from "react";
// --- 1. Define Service Hook (Manages slice-specific client state) ---
type Theme = "light" | "dark";
const useUserPreferencesService = () => {
const [theme, setTheme] = useState<Theme>("light");
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
// Use useCallback for stable function references
const toggleTheme = useCallback(() => {
setTheme((current) => (current === "light" ? "dark" : "light"));
}, []);
const toggleNotifications = useCallback(() => {
setNotificationsEnabled((current) => !current);
}, []);
// Memoize the context value to prevent unnecessary re-renders
const serviceValue = useMemo(
() => ({
theme,
notificationsEnabled,
toggleTheme,
toggleNotifications,
}),
[theme, notificationsEnabled, toggleTheme, toggleNotifications],
);
return serviceValue;
};
// --- 2. Create Context & Consumer Hook ---
type UserPreferencesContextType = ReturnType<
typeof useUserPreferencesService
> | null;
const UserPreferencesContext = createContext<UserPreferencesContextType>(null);
export const useUserPreferences = () => {
const context = useContext(UserPreferencesContext);
if (!context) {
throw new Error(
"useUserPreferences must be used within a UserPreferencesProvider",
);
}
return context;
};
// --- 3. Create Provider Component (Boundary of the slice's UI) ---
export const UserPreferencesProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const service = useUserPreferencesService();
return (
<UserPreferencesContext.Provider value={service}>
{/* Pass the service value down */}
{children}
</UserPreferencesContext.Provider>
);
};
// --- Usage within the Conceptual Feature Slice ---
// Example component consuming the theme
const ThemeDisplay = () => {
const { theme } = useUserPreferences(); // Consume context
return <p>Current Theme: {theme}</p>;
};
// Example component consuming an action
const ThemeToggleButton = () => {
const { toggleTheme } = useUserPreferences(); // Consume context
return <button onClick={toggleTheme}>Toggle Theme</button>;
};
// Root UI for this conceptual feature slice
const UserPreferencesFeature = () => {
// This component might fetch global data itself using React Query,
// e.g., const { data: user } = useQuery(['user', userId]);
// but the theme logic is self-contained via the Provider/Context.
return (
<UserPreferencesProvider>
<h2>User Preferences</h2>
<ThemeDisplay />
<ThemeToggleButton />
{/* Other components related to user preferences could go here */}
{/* e.g., <NotificationToggle /> */}
</UserPreferencesProvider>
);
};
export default UserPreferencesFeature; // Example export
Combining the Three-Tier pattern within a Conceptual Vertical Slice, using React Query (as a global ancestor connecting components directly to server state cache, crossing slice boundaries cleanly) and Context (as a slice-scoped ancestor for stable service logic within the boundary), allows building modular, maintainable, and performant features aligned with Reactβs core Tree rules.
Improving Testability with Three Tiers
Testing components becomes significantly easier when you separate concerns using the three-tier architecture. Monolithic components, which mix data fetching, business logic, UI state, and rendering, create complex and brittle tests.
Challenges of Testing Monolithic Components
Consider testing a MonolithicUserProfile component that performs multiple tasks:
- Fetches user data and permissions.
- Uses a global store.
- Calculates derived state (like
canEditProfile) based on multiple data sources. - Handles local UI state (like form inputs).
- Renders UI elements.
- Includes functions to update data via API calls (
handleSave).
// Example: Testing a monolithic component
// Mock all external dependencies the component might interact with.
// This is necessary because the component's logic is tightly coupled.
jest.mock("react-query");
jest.mock("./stores");
jest.mock("./api");
describe("MonolithicUserProfile", () => {
// Skipped: Testing derived state like this often requires complex setup
// involving rendering or hook extraction, similar to the save test.
it.skip("correctly determines edit capability", () => {
/* ... */
});
it("calls the save API with correct data", async () => {
// 1. Provide mock implementations for all dependencies.
// We need to mock user data, permissions, and the store, even though
// this test primarily focuses on the save action.
(useQuery as jest.Mock)
.mockReturnValueOnce({
data: { id: 1, name: "User Name" },
isLoading: false,
})
.mockReturnValueOnce({ data: { canEdit: true }, isLoading: false });
(useSomeStore as jest.Mock).mockReturnValue({ isAdmin: false });
const mockUpdateUserProfile = jest.fn().mockResolvedValue({});
(updateUserProfile as jest.Mock) = mockUpdateUserProfile;
// 2. Render the component. Note: Requires provider setup (omitted).
const { unmount } = render(<MonolithicUserProfile userId="1" />);
// 3. Simulate user events to trigger the save logic.
const inputField = screen.getByLabelText("User Name"); // Use appropriate selectors
fireEvent.change(inputField, { target: { value: "New Name" } });
const saveButton = screen.getByRole("button", { name: /save/i });
fireEvent.click(saveButton);
// 4. Assert that the correct API call was made.
await waitFor(() => {
expect(mockUpdateUserProfile).toHaveBeenCalledWith(
"1",
expect.objectContaining({ name: "New Name" })
);
});
unmount(); // Clean up the rendered component
});
});
Simplified Testing with the Three-Tier Structure
Separating concerns into Repository, Service, and Controller layers dramatically simplifies testing because you can test each layer in isolation.
1. Testing Repository Hooks:
- Focus: Verify that the hook correctly interacts with the data source (e.g., calls the right API function with the correct arguments).
- Mocking: Mock only the raw data fetching function (e.g.,
api.fetchUser). - Method: Use
renderHookfrom@testing-library/react-hooks.
// Example: Testing a repository hook
import { renderHook } from "@testing-library/react-hooks";
import { useUserDataRepository } from "./userData.repository";
import * as api from "./api";
// Mock only the direct dependency: the API module.
jest.mock("./api");
describe("useUserDataRepository", () => {
it("calls the correct API function for fetchUser", async () => {
// Arrange: Set up the mock API function.
const mockUser = { id: 1, name: "Test" };
(api.fetchUser as jest.Mock).mockResolvedValue(mockUser);
// Act: Render the hook and call the function under test.
const { result } = renderHook(() => useUserDataRepository());
const user = await result.current.fetchUser("1");
// Assert: Verify the API was called correctly and data returned.
expect(api.fetchUser).toHaveBeenCalledWith("1");
expect(user).toEqual(mockUser);
});
});
2. Testing Service Hooks:
- Focus: Verify business logic, data transformation, and orchestration of repositories.
- Mocking: Mock the Repository hooks that the Service consumes.
- Method: Use
renderHook, potentially with a wrapper if the service uses hooks likeuseQuerythat require context providers.
// Example: Testing a service hook
import { renderHook } from "@testing-library/react-hooks";
import { useUserProfileService } from "./userProfile.service";
import { useUserDataRepository } from "./userData.repository"; // The hook's dependency
import { QueryClient, QueryClientProvider } from "react-query";
// Mock the consumed repository hook.
jest.mock("./userData.repository");
// Provides React Query context needed by the service's useQuery call.
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, // Disable retries for tests
});
return ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe("useUserProfileService", () => {
it("constructs the profile title correctly", async () => {
// Arrange: Mock the repository's fetchUser function.
const mockFetchUser = jest
.fn()
.mockResolvedValue({ id: 1, name: "Test User" });
(useUserDataRepository as jest.Mock).mockReturnValue({
fetchUser: mockFetchUser,
});
// Act: Render the service hook with the wrapper.
const { result, waitFor } = renderHook(() => useUserProfileService("1"), {
wrapper: createWrapper(),
});
// Wait for useQuery to resolve its data.
await waitFor(() => !result.current.isLoading);
// Assert: Verify the repository was called and the title is correct.
expect(mockFetchUser).toHaveBeenCalledWith("1");
expect(result.current.profileTitle).toBe("Profile: Test User");
});
});
3. Testing Components (Controllers):
- Focus: Verify rendering based on props and service hook state, and ensure UI interactions correctly call service functions.
- Mocking: Mock the Service hooks that the component consumes.
- Method: Use
renderfrom@testing-library/reactand simulate user interactions.
// Example: Testing a component (controller)
import { render, screen } from "@testing-library/react";
import { UserProfileDisplay } from "./UserProfileDisplay";
import { useUserProfileService } from "./userProfile.service"; // The component's dependency
// Mock the consumed service hook.
jest.mock("./userProfile.service");
describe("UserProfileDisplay", () => {
it("renders the profile title from the service", () => {
// Arrange: Mock the service hook's return value.
(useUserProfileService as jest.Mock).mockReturnValue({
profileTitle: "Profile: Mock User",
isLoading: false,
});
// Act: Render the component.
render(<UserProfileDisplay userId="1" />);
// Assert: Verify the title is displayed correctly.
expect(
screen.getByRole("heading", { name: /Profile: Mock User/i })
).toBeInTheDocument();
});
it("shows loading state", () => {
// Arrange: Mock the service hook for the loading state.
(useUserProfileService as jest.Mock).mockReturnValue({
profileTitle: "",
isLoading: true,
});
// Act: Render the component.
render(<UserProfileDisplay userId="1" />);
// Assert: Verify the loading indicator is displayed.
expect(screen.getByText(/Loading.../i)).toBeInTheDocument();
});
// Additional tests would verify interactions, e.g., clicking a button
// that calls a function provided by the mocked service hook.
});
By isolating responsibilities, the three-tier architecture produces tests that are simpler, faster, more focused, and less brittle. This aligns with managing complexity within the React Tree model: you test smaller, predictable units of logic instead of large, tangled components.
Conclusion: Predictable React
React rendering is predictable when viewed as a Tree governed by data flow (Rule #1) and cascading re-renders (Rule #2). State location (local vs. global/contextual ancestor) determines the starting point and scope of these cascades.
Use patterns to manage this system effectively:
- Three-Tier Hooks: Structure logic (Repository, Service, Controller) to separate concerns.
- Vertical Slicing: Organize code by feature for better colocation.
- Push State Down: Critically, keep state as close as possible to where itβs used to minimize the impact of Rule #2.
- React Query & Context: Leverage these as specialized βancestorsβ to efficiently manage server state and stable feature-specific logic according to the Tree rules, complementing pushed-down local state.
This layered approach, guided by the core principles, leads to cleaner, more testable, maintainable, and performant React applications. Always ask:
- βWhatβs rendering and why (Rule #2)?β
- βIs state located as low as possible? How can I limit dependencies and minimize the render scope?β
- βHow can I make communication efficient (Rule #1)?β
- βHow can I better colocate this featureβs code?β