Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Writing Readable React Components: Six Habits, One Messy Example

About Post

Every React codebase has one. A component called something like Page or Dashboard, 400 lines long, with eleven useState calls, three useEffects that depend on each other, and props named data, flag and cb.

Nobody wrote it like that on purpose. It grew, one "quick addition" at a time, until changing anything felt like defusing a bomb.

Readable components aren't about style preferences. They're about how fast the next person (often you, three months later) can answer "where does this value come from, and what happens if I change it?" Let's take a typical messy component and fix it habit by habit.

The starting point

Here's a trimmed-down version of the kind of component I mean, a list of maintenance requests:

export default function Page({ data, user, flag, cb }) {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const [filter, setFilter] = useState('open');
  const [count, setCount] = useState(0);
  const [modalOpen, setModalOpen] = useState(false);

  useEffect(() => {
    fetch(`/api/users/${user.id}/maintenance-requests`)
      .then((r) => r.json())
      .then((d) => { setItems(d); setLoading(false); });
  }, [user.id]);

  useEffect(() => {
    setCount(items.filter((i) => i.status === filter).length);
  }, [items, filter]);

  if (loading) return <Spinner />;
  return <div>{/* 150 more lines of tabs, rows and a modal */}</div>;
}

It works. It's just hard to read, hard to test and easy to break.

Habit 1: names that say what things are

Page, data, flag and cb force every reader to go and find out. A few conventions remove the guesswork:

  • Components are named for what they show: MaintenanceRequestsPage, RequestRow.
  • Booleans read as a yes/no question: isReadOnly, hasUnread, canApprove.
  • Callback props start with on (onSelect), and the functions you pass to them start with handle (handleSelect).
  • Data props are named for the data, not its shape: requests, not items or list.

And watch for boolean prop explosions. <Button primary small danger outline /> allows combinations that make no sense. <Button variant="danger" size="sm" /> doesn't.

Habit 2: derive, don't sync

That count state and the effect that updates it are a classic. Any value you can calculate from props or other state should simply be calculated during render:

const visibleRequests = requests.filter((r) => r.status === status);
const visibleCount = visibleRequests.length;

One less piece of state, one less effect, and no render where the count is briefly out of date. If the calculation is genuinely expensive, wrap it in useMemo. Otherwise, don't bother.

Habit 3: move logic into custom hooks

The component shouldn't have to know how requests are fetched. That's a job for a custom hook, which gives the logic a name and a single home:

function useMaintenanceRequests(userId) {
  const [state, setState] = useState({ requests: [], isLoading: true, error: null });

  useEffect(() => {
    let ignore = false;
    fetch(`/api/users/${userId}/maintenance-requests`)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((requests) => !ignore && setState({ requests, isLoading: false, error: null }))
      .catch((error) => !ignore && setState({ requests: [], isLoading: false, error }));

    return () => { ignore = true; };
  }, [userId]);

  return state;
}

This is simplified. In a real app I'd usually let a data-fetching library such as TanStack Query handle caching and retries. The nice thing is that the hook hides that decision: swap the inside later and no component has to change.

Habit 4: split by responsibility

A useful test: describe what the component does in one sentence. If the sentence contains "and" more than once, it's probably several components. Our page fetches requests and shows filter tabs and renders rows and manages a modal.

After habits 1 to 4, the page reads almost like a sentence:

export default function MaintenanceRequestsPage({ userId }) {
  const { requests, isLoading, error } = useMaintenanceRequests(userId);
  const [status, setStatus] = useState('open');

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;

  const visibleRequests = requests.filter((r) => r.status === status);

  return (
    <>
      <RequestFilterTabs value={status} onChange={setStatus} />
      <RequestList requests={visibleRequests} emptyText="No requests here yet" />
    </>
  );
}

Notice the early returns for loading and error. They keep the main return focused on the normal case, instead of burying it in nested ternaries.

Habit 5: keep state close to where it's used

Where did modalOpen go? It moved into RequestRow, because that's the only place that opens the modal. State at the top of a page means every change re-renders the whole page and every reader has to trace it down through props.

The rule: put state as low as possible, and lift it only as high as the closest component that really needs it.

Habit 6: avoid prop drilling, in the right order

Prop drilling is passing a prop through components that don't use it, just to reach one that does. The first fix isn't context. It's composition: let the parent pass the finished element instead of the raw data.

// Drilling: Layout never uses user, it only passes it on
<Layout user={user} />

// Composition: Layout just places what it's given
<Layout header={<UserMenu user={user} />}>
  <Dashboard />
</Layout>

Reach for context when data is genuinely needed in many places at different depths: the current user, theme, locale, permissions. Not for everything, because every context consumer re-renders when the value changes, and "where does this come from?" gets harder to answer.

A quick readability check: can someone understand what a component renders by reading only its return statement and the names above it? If they need to read the effects to know what's on screen, something can be named, derived or extracted.

The checklist

  • Names that say what things are; is/has/can for booleans, on for callbacks.
  • Derive values during render instead of syncing them with effects.
  • Logic in custom hooks, rendering in components.
  • One responsibility per component; early returns for loading and errors.
  • State as low as possible.
  • Composition before context; context before a global store.

The React docs have a great page on this, You Might Not Need an Effect, which I'd recommend to anyone who writes React daily.

What's the first thing you refactor when you open a messy component? For me it's always the effects that sync state.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close