7 min read

React Patterns - A Different Look at Predictable UIs

Table of Contents

React Patterns: A Different Look

Ever built a React component that started simple but grew into a tangle? Maybe something like this:

import React, { useState, useEffect } from "react";
import { fetchUserProfile, updateUserProfile } from "./api";
import { useNotifications } from "./notificationStore"; // Some global store

function MessyUserProfile({ userId }) {
  const [name, setName] = useState("");
  const [bio, setBio] = useState("");
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
  const { showNotification } = useNotifications();

  // Fetch data on load
  useEffect(() => {
    setIsLoading(true);
    fetchUserProfile(userId)
      .then((data) => {
        setName(data.name);
        setBio(data.bio);
        setError(null);
      })
      .catch((err) => {
        setError("Failed to load profile.");
        console.error(err);
      })
      .finally(() => setIsLoading(false));
  }, [userId]);

  // Handle saving changes
  const handleSave = async () => {
    try {
      await updateUserProfile(userId, { name, bio });
      showNotification("Profile saved!");
    } catch (err) {
      showNotification("Save failed!", "error");
      console.error(err);
    }
  };

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p style={{ color: "red" }}>{error}</p>;

  return (
    <div>
      <h2>Edit Profile</h2>
      <label>
        Name:
        <input value={name} onChange={(e) => setName(e.target.value)} />
      </label>
      <br />
      <label>
        Bio:
        <textarea value={bio} onChange={(e) => setBio(e.target.value)} />
      </label>
      <br />
      <button onClick={handleSave}>Save Profile</button>
    </div>
  );
}

It works! But… it’s doing a lot. Data fetching, state management (local and global), error handling, rendering. Testing this feels tricky. What if the API changes? What if the notification system changes? It’s all mixed together.

Let’s untangle this, guided by how React actually thinks.

React Thinks in Trees

Imagine your app isn’t just components, but a Tree. Like a family tree.

      ┌───┐
      │App│
      └┬──┘
 ┌─────▽─────┐
 │ PageLayout│
 └─────┬─────┘
┌──────▽──────────────┐
│   UserProfilePage   │
└──────┬──────────────┘

┌──────┐
│ ProfileForm │  <-- Our MessyUserProfile lives here maybe
└──────┘

This tree has two simple rules:

  1. Data Flows Down: Like passing heirlooms, props go from parent to direct child. Siblings? They chat via their closest common ancestor.
  2. Updates Cascade: If a component re-renders (state changed? props changed? parent re-rendered?), its entire subtree re-renders too, by default. Oof.

That second rule is the big one for performance. Where you put your useState matters. If App holds state that only ProfileForm cares about, changing that state re-renders everything below App.

Keeping state low, close to where it’s used, stops the cascade early. Less work for React, faster app.

Where Does State Live?

Our MessyUserProfile has name and bio state. That’s Local State. React keeps track of it for that specific component instance. When setName runs:

  1. React notes the new name for this MessyUserProfile.
  2. React re-runs the MessyUserProfile function.
  3. Rule #2 kicks in: MessyUserProfile and anything inside it re-renders.

Simple enough. But what about things needed across distant branches of the tree? Like our useNotifications? That’s Global State (could be Context, Zustand, Redux, Jotai, even the URL).

Think of global state like a shared bulletin board (a common ancestor way up the tree) that specific components subscribe to.

      [Root]
      /    \              +-----------------+
   [A] ---- [B]           | External State  |
  /   \      |             |(Bulletin Board) |
[A1]  [A2] -- [B1] <------ +-----------------+
        ^      ^           (Components subscribe)

The Tree rules still apply. A component subscribes (Rule #1 variation), and when the relevant part of the board changes, the subscriber re-renders its subtree (Rule #2). Good global state tools let you subscribe precisely, minimizing the cascade.

Untangling Step 1: The Data Fetching

Back to MessyUserProfile. That useEffect for data fetching… it’s just about how to get the data. Let’s pull it out.

// userData.repository.js
import { useState, useEffect, useCallback } from "react";
import { fetchUserProfile, updateUserProfile } from "./api";

// This hook only knows HOW to talk to the user API
export function useUserDataRepository(userId) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  const loadUser = useCallback(() => {
    setIsLoading(true);
    fetchUserProfile(userId)
      .then((data) => {
        setUser(data);
        setError(null);
      })
      .catch((err) => {
        setError("Failed to load profile.");
        console.error(err);
        setUser(null); // Clear stale data on error
      })
      .finally(() => setIsLoading(false));
  }, [userId]);

  // Load on initial mount or when userId changes
  useEffect(() => {
    loadUser();
  }, [loadUser]); // Use the memoized loader

  const saveUser = useCallback(
    async (data) => {
      // Let the caller handle success/error notifications
      return updateUserProfile(userId, data);
    },
    [userId],
  );

  return { user, isLoading, error, reloadUser: loadUser, saveUser };
}

Nice! This useUserDataRepository is focused. It fetches, it saves. It doesn’t know why, just how. We call these Repository Hooks. They handle the mechanics of data access (API, local storage, DOM, whatever).

Testing useUserDataRepository is now way easier. We just need to mock ./api and check if the right functions are called. No UI rendering needed.

Untangling Step 2: The Business Logic

Our component still needs to manage the form state (name, bio) and decide when to save, maybe coordinate with notifications. This feels like orchestration, the what and why. Let’s make a hook for that.

// userProfile.service.js
import { useState, useEffect, useCallback } from "react";
import { useUserDataRepository } from "./userData.repository";
import { useNotifications } from "./notificationStore";

// This hook orchestrates: uses the repository, manages form state, handles notifications
export function useUserProfileService(userId) {
  const { user, isLoading, error, reloadUser, saveUser } =
    useUserDataRepository(userId);
  const { showNotification } = useNotifications();

  // Form state - initialize from fetched data
  const [name, setName] = useState("");
  const [bio, setBio] = useState("");

  useEffect(() => {
    if (user) {
      setName(user.name || "");
      setBio(user.bio || "");
    }
  }, [user]); // Update form when user data loads/changes

  const handleSave = useCallback(async () => {
    try {
      await saveUser({ name, bio });
      showNotification("Profile saved!");
      // Maybe reload data after save?
      // reloadUser();
    } catch (err) {
      showNotification("Save failed!", "error");
      console.error(err);
      // Don't reload on failure, let user retry
    }
  }, [saveUser, name, bio, showNotification /*, reloadUser */]);

  return {
    // Data state from repository
    isLoading,
    error,
    // Form state and setters
    name,
    setName,
    bio,
    setBio,
    // Actions
    handleSave,
    // Maybe expose reload if needed by UI
    // reloadUser
  };
}

This useUserProfileService is our Service Hook. It uses Repositories, contains the core feature logic, transforms data if needed, and decides when actions happen (like calling saveUser and showNotification).

Testing this? Mock the repository (useUserDataRepository) and the notification hook. Easier again!

The Component’s New Job: Just Connect

So what’s left for our component? Just wiring things up!

// UserProfileForm.jsx (formerly MessyUserProfile)
import React from "react";
import { useUserProfileService } from "./userProfile.service";

// This component is now a "Controller" - connects UI to the service logic
function UserProfileForm({ userId }) {
  const { isLoading, error, name, setName, bio, setBio, handleSave } =
    useUserProfileService(userId);

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p style={{ color: "red" }}>{error}</p>; // Could make error display nicer

  return (
    <div>
      <h2>Edit Profile</h2>
      <label>
        Name:
        <input value={name} onChange={(e) => setName(e.target.value)} />
      </label>
      <br />
      <label>
        Bio:
        <textarea value={bio} onChange={(e) => setBio(e.target.value)} />
      </label>
      <br />
      <button onClick={handleSave}>Save Profile</button>
    </div>
  );
}

Look how clean that is! The component focuses purely on presentation and connecting UI events (onChange, onClick) to the service logic (setName, handleSave). It doesn’t know how data is fetched or exactly what happens on save. We call this the Controller layer (often just the component itself).

This separation – Repository (how), Service (what/why/when), Controller: Connect) – makes our code:

  • Easier to Understand: Each part has a clear job.
  • Easier to Test: Test layers in isolation.
  • More Reusable: useUserProfileService could potentially drive a different UI. useUserDataRepository could be used by other services.

Keeping Things Together

Okay, we have UserProfileForm.jsx, userProfile.service.js, userData.repository.js, maybe an api.js. They all relate to the “user profile” feature. Wouldn’t it be nice to keep them together?

src/
└── features/
    └── user-profile/
        ├── UserProfileForm.jsx
        ├── userProfile.service.js
        ├── userData.repository.js
        ├── api.js // Or maybe api calls live elsewhere if shared
        └── index.js // Exports UserProfileForm for easy import

This is Vertical Slicing. Group code by feature, not by type (like putting all hooks in /hooks, all components in /components). It makes features more self-contained. Need to change the profile feature? It’s likely all in this folder.

How does Context fit in? Sometimes, a service hook provides stable functions or derived data needed by multiple components within the feature slice. You could use Context within the slice’s top component to provide this down the “feature subtree” without prop drilling.

// Simplified example within user-profile/UserProfileFeature.jsx
import React, { createContext, useContext } from "react";
import { useUserProfileService } from "./userProfile.service";
import { SomeChildComponent } from "./SomeChildComponent"; // Needs stable actions

const ProfileContext = createContext(null);

export function useProfileContext() {
  const context = useContext(ProfileContext);
  if (!context) throw new Error("Missing ProfileContext");
  return context;
}

function UserProfileFeature({ userId }) {
  const service = useUserProfileService(userId);

  // IMPORTANT: Memoize or provide only stable parts (like handleSave)
  // to avoid unnecessary context re-renders.
  const contextValue = useMemo(
    () => ({
      handleSave: service.handleSave,
      // Don't put frequently changing things like 'name' here unless necessary
      // and consumers are optimized.
    }),
    [service.handleSave],
  );

  return (
    <ProfileContext.Provider value={contextValue}>
      {/* Pass service props down directly where needed */}
      <UserProfileForm /* pass props like name, setName etc */ />
      <SomeChildComponent /> {/* Can use useProfileContext() */}
    </ProfileContext.Provider>
  );
}

Context acts like a local bulletin board just for this feature slice, complementing the global one (useNotifications) and local state (useState still belongs low down!).

Conclusion: Building Predictably

React rendering isn’t magic. It’s a Tree with rules. Data flows down, updates cascade.

Our journey from MessyUserProfile showed us how to work with these rules:

  1. See the Tree: Understand how components relate and how updates flow (Rule #1 & #2).
  2. Keep State Low: Minimize the cascade (Rule #2) by putting useState close to where it’s needed.
  3. Separate Concerns: Untangle components naturally into layers (Repository: How, Service: What/Why, Controller: Connect). This makes testing sane.
  4. Group by Feature: Use Vertical Slicing for better organization.
  5. Use Tools Wisely: Context and global state are tools to manage communication across the tree efficiently.

It’s not about rigid rules, but about understanding the flow. Ask yourself:

  • “What’s rendering and why?”
  • “Can this state live lower down?”
  • “Is this component doing too much?”
  • “Can I test this part easily?”

Thinking this way leads to React apps that are easier to build, test, and maintain. Less mess, more predictability.