Building Better React Apps: Evolving Patterns for Maintainable UIs

Building Better React Apps: Evolving Patterns for Maintainable UIs
Photo by drmakete lab / Unsplash

As React continues to evolve, the way we write components—and structure logic within them—needs to mature as well. While it's tempting to just "make it work," that mindset often leads to tech debt and painful refactors later on.

Here are modern React patterns and best practices that will help you build apps that are not just functional, but also clean, predictable, and scalable.


1. UIs Are a Thin Wrapper Over Data

At the heart of good React design is this mindset: your UI should reflect state, not manage it unnecessarily.

Avoid using useState for things that can be derived from props or external state. This principle keeps your components lean and makes testing, debugging, and reasoning about them much easier.

Example: Instead of storing a boolean isFormValid, derive it from the actual form data using a validation function.

2. Prefer Calculated State Over Local State

Before reaching for useState, ask yourself:
Can this value be computed on render instead of stored?

If so, compute it. Derived state avoids issues with stale values and side effects that don’t sync properly.

Bad:
const [isDisabled, setDisabled] = useState(false);
Better:
const isDisabled = !form.email || !form.password;

This keeps logic declarative and easier to follow.


3. Use State Machines for Complex UI Logic

React’s useState works well for simple toggles and form fields. But once you find yourself juggling multiple booleans or dependent state values, state machines (like XState) can make your logic far more predictable.

Why? State machines force you to define every possible state explicitly. No more “what happens if both isLoading and isError are true?”

When logic becomes non-linear, managing transitions clearly matters.


4. Avoid Deep Nesting and Logic in JSX

It’s tempting to write nested ternaries or inline if conditions inside JSX. But nesting hurts readability.

Instead of this:

{isLoggedIn ? (
  hasPermission ? <Dashboard /> : <Unauthorized />
) : (
  <LoginForm />
)}

Try abstracting into a function:

function renderContent() {
  if (!isLoggedIn) return <LoginForm />;
  if (!hasPermission) return <Unauthorized />;
  return <Dashboard />;
}

Readable logic is maintainable logic.


5. Be Careful with useEffect Logic

useEffect is often misused as a dumping ground for business logic. That leads to confusing side effects, unexpected re-renders, and difficult-to-debug bugs.

Use it only for what it’s intended: side effects, like:

  • Fetching data
  • Setting up subscriptions
  • Interacting with the DOM

Avoid putting decision-making logic in it. If your effect depends on certain values, make that explicit and minimal.


6. Prefer Explicit Over Implicit Reactivity

React’s reactive model is powerful—but implicit dependencies (like depending on a changing state without tracking it properly) create confusion.

Instead of relying on useEffect to do things “when something changes,” use clear event handlers and state transitions where possible.

Avoid this:
useEffect(() => {
  if (count > 10) {
    setWarning(true);
  }
}, [count]);
Prefer:
const handleClick = () => {
  if (count > 10) {
    setWarning(true);
  }
}

7. Avoid setTimeout Unless Absolutely Necessary

Using setTimeout in React is often a sign of hacks or timing workarounds. It may work, but it's not reliable and makes behavior unpredictable—especially with re-renders.

If you must use it (e.g., for debouncing or delaying animations), always comment why it exists. Also consider alternatives like:

  • useDebounce hooks
  • Controlled animation libraries (e.g., Framer Motion)

8. Keep Business Logic Out of the UI Layer

Don’t let your React components become the dumping ground for business rules. If you’re:

  • Validating form data
  • Applying discounts
  • Deciding permissions

…then put that in dedicated utility functions or services, not inline inside components.

This decouples your logic from your UI, makes it easier to test, and allows you to re-use it across different UIs (e.g., web and mobile).


9. Think in Events and Transitions

Rather than constantly syncing state based on values, think in terms of events.
“What just happened?” → “What should happen next?”

This shift in mindset leads to more reliable app behavior, especially when paired with reducers or state machines.


10. Test the Logic, Not the DOM

Don’t write too many fragile UI tests. Instead, test:

  • Reducers
  • Validators
  • Business rules

Unit testing the underlying logic is faster, more stable, and provides better coverage than DOM-heavy tests.

Use libraries like @testing-library/react for basic interaction testing, but avoid snapshot testing for logic-heavy components.


✨ Finally

Just because the code runs, doesn't mean it's right.

React won’t throw errors when patterns are misused. But the bugs pile up quietly, and the next developer (maybe you) has to untangle it.

By following these evolving patterns:

  • Your code will be easier to reason about
  • Refactoring will be less painful
  • Your future self (and teammates) will thank you

Let’s stop thinking “just make it work” and start thinking “make it last.”

Support Us