DEV Community

Cover image for Back to React Fundamentals: useEffect, useState, useMemo, useCallback, useReducer
kensaadi
kensaadi

Posted on

Back to React Fundamentals: useEffect, useState, useMemo, useCallback, useReducer

Updated for React 19.2 (2025)

Over the last few years, the React ecosystem has become increasingly complex.

Server Components, compilers, signals, state managers, meta-frameworks, cache layers, streaming, optimistic UI.

But while everyone is chasing the next big thing, many real-world React problems still come from the same places:

  • poorly used useEffect
  • misunderstood dependencies
  • unnecessary re-renders
  • premature memoization
  • oversized components
  • badly distributed state

This article is a return to fundamentals.

Not theoretical concepts, but the things that truly matter when building scalable and maintainable React applications.


Latest: React 19.2 Release (October 2025)

Concurrent rendering was introduced in React 18 via createRoot, not React 19.

React 19.2 adds several important features:

  • useEffectEvent — Separate reactive from non-reactive logic in effects
  • <Activity /> — Hide and restore UI sections with independent state management
  • cacheSignal — Know when server component cache lifetime is over (RSCs only)
  • Performance Tracks — New Chrome DevTools performance profiling
  • Partial Pre-rendering — Pre-render static parts and resume with dynamic content later

Key insight: These features build on the fundamentals below. Master state colocation, dependency management, and component splitting first—then layer in React 19's advanced capabilities.


First rule: React renders functions

When a React component updates:

  • the component function runs again
  • all hooks run again
  • objects and functions declared inside are recreated
  • React performs reconciliation to identify what actually changed

Example:

function Counter() {
  console.log('Counter render');

  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every click:

  • re-executes Counter
  • recreates the onClick function
  • reevaluates the JSX

This is completely normal.

The real problem starts when:

  • the component becomes huge
  • it contains too much logic
  • it renders expensive children
  • it passes unstable props
  • it centralizes too much state

And this leads us to the first truly important concept.

Splitting code into components

One of the most powerful ways to optimize React is not useMemo.

It's splitting components correctly.

The most common mistake is putting too much state and UI inside the same component.

Problematic example

function Dashboard() {
  const [count, setCount] = useState(0);

  console.log('Dashboard render');

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Increment: {count}
      </button>

      <ExpensiveChart />
      <ComplexTable />
      <HeavySidebar />
    </div>
  );
}

function ExpensiveChart() {
  console.log('ExpensiveChart render');

  return <div>Chart</div>;
}

function ComplexTable() {
  console.log('ComplexTable render');

  return <div>Table</div>;
}

function HeavySidebar() {
  console.log('HeavySidebar render');

  return <div>Sidebar</div>;
}
Enter fullscreen mode Exit fullscreen mode

Every click updates the state of Dashboard.

As a consequence:

  • Dashboard re-runs
  • JSX is recreated
  • child React elements are recreated
  • React reevaluates the child tree

So you might see:

  • Dashboard render
  • ExpensiveChart render
  • ComplexTable render
  • HeavySidebar render

even though those components do not depend on count.

The issue is not the button.

The issue is that the state lives too high in the tree.

Solution: move state closer to where it's actually needed

function Dashboard() {
  console.log('Dashboard render');

  return (
    <div>
      <CounterSection />

      <ExpensiveChart />
      <ComplexTable />
      <HeavySidebar />
    </div>
  );
}

function CounterSection() {
  const [count, setCount] = useState(0);

  console.log('CounterSection render');

  return (
    <button onClick={() => setCount(count + 1)}>
      Increment: {count}
    </button>
  );
}

function ExpensiveChart() {
  console.log('ExpensiveChart render');

  return <div>Chart</div>;
}

function ComplexTable() {
  console.log('ComplexTable render');

  return <div>Table</div>;
}

function HeavySidebar() {
  console.log('HeavySidebar render');

  return <div>Sidebar</div>;
}
Enter fullscreen mode Exit fullscreen mode

Now when you click the button:
render

This is the crucial difference:

A child state update does not automatically force parent components to re-render.

React starts the update from the component whose state changed and only re-renders the necessary part of the tree.

Before refactoring, the state lived in Dashboard, so every click forced Dashboard to run again and recreate the child tree. All children would be re-evaluated as a result.

After refactoring, the state lives inside CounterSection, so the update stays local. The state update triggers a re-render of CounterSection only.

Because Dashboard no longer updates, React has no reason to traverse and re-render those children. ExpensiveChart, ComplexTable, and HeavySidebar are not affected by this state change.

This is the core principle: state that affects only a small part of the UI should live in that part. Where state lives determines where reconciliation starts and what gets re-rendered.

This is the real value of component splitting

You didn't use:

  • useMemo
  • useCallback
  • React.memo

You simply:

  • isolated responsibilities
  • localized state
  • reduced update propagation

Note on React.memo:

React.memo has legitimate uses—wrapping expensive components that receive stable props, or protecting components that receive object/function props. But it's a second-line optimization. Component splitting solves 90% of performance problems without it.

This is one of the most important React optimizations:

State colocation

Keeping state as close as possible to where it is used.

Practical rule

If a piece of state only belongs to a small part of the UI, don't put it in the parent component.

Better:

function Page() {
  return (
    <>
      <Header />
      <SearchBox />
      <Results />
    </>
  );
}

function SearchBox() {
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={(event) => setQuery(event.target.value)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Worse:

function Page() {
  const [query, setQuery] = useState('');

  return (
    <>
      <Header />
      <SearchBox
        query={query}
        setQuery={setQuery}
      />
      <Results />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the second example, every keystroke may rerun the entire Page.

In the first one, the update stays local.

Component splitting vs Lazy Loading

These two concepts are often confused.

But they solve completely different problems.

Component Splitting

Used for:

  • isolating responsibilities
  • limiting state propagation
  • improving maintainability
  • making renders more predictable

Lazy Loading

Used for:

  • loading code only when needed
  • reducing the initial bundle size
  • improving initial loading performance

Example:

const AdminPage = lazy(() => import('./AdminPage'));
Enter fullscreen mode Exit fullscreen mode

This does NOT optimize renders.

It optimizes bundle downloading.

useState: values vs references

Before truly understanding useEffect, you need to understand something fundamental:

in JavaScript there are:

  • primitive values
  • references

Primitive values

const a = 5;
const b = 5;

console.log(a === b); // true
Enter fullscreen mode Exit fullscreen mode

Objects and arrays

const a = { name: 'Ken' };
const b = { name: 'Ken' };

console.log(a === b); // false
Enter fullscreen mode Exit fullscreen mode

Why?

Because JavaScript compares memory references.

Not contents.

This concept is ESSENTIAL for understanding:

  • useEffect
  • useMemo
  • useCallback
  • React.memo
  • re-renders

useEffect: it's not really componentDidMount

Many developers still think of useEffect as:

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

It's a useful beginner mental model.

But it's not what useEffect truly is.

useEffect exists to synchronize React with the outside world.

Examples:

  • APIs
  • timers
  • websockets
  • localStorage
  • browser events
  • analytics

useEffect equivalent to didMount

useEffect(() => {
  console.log('mounted');
}, []);
Enter fullscreen mode Exit fullscreen mode

The empty dependency array means:

run the effect only on mount.

Real-world example: API call on mount

For fetch-on-mount scenarios, the nested function pattern is the modern best practice:

function UsersPage() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    async function loadUsers() {
      const response = await fetch('/api/users');

      const data = await response.json();

      setUsers(data);
    }

    loadUsers();
  }, []);

  return <UsersTable users={users} />;
}
Enter fullscreen mode Exit fullscreen mode

Why this pattern?

  • The function is defined inside useEffect, so it doesn't need to be a dependency
  • No memoization overhead
  • Simpler and more readable
  • The function is isolated to where it's actually used

When you DO need useCallback with useEffect

Only reach for useCallback when loadUsers needs to be called elsewhere or exposed as a stable reference:

function UsersPage() {
  const [users, setUsers] = useState([]);

  const loadUsers = useCallback(async () => {
    const response = await fetch('/api/users');

    const data = await response.json();

    setUsers(data);
  }, []);

  // ✅ Use useCallback here because:
  // - loadUsers is a dependency of useEffect
  // - loadUsers is passed as a prop to child components
  // - loadUsers might be called from multiple places

  useEffect(() => {
    loadUsers();
  }, [loadUsers]);

  return (
    <div>
      <RefreshButton onRefresh={loadUsers} />
      <UsersTable users={users} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The key difference:

  • Nested function: Simpler, no memoization. Use when the function only belongs inside useEffect.
  • useCallback: Overhead, but necessary when the function is a dependency or needs to be stable across renders.

Default approach: Start with the nested function. Only add useCallback if you actually need a stable reference.

Why you should NOT use async directly in useEffect

Common mistake:

useEffect(async () => {
  // NO
}, []);
Enter fullscreen mode Exit fullscreen mode

Because useEffect expects:

  • void
  • or a cleanup function

An async function always returns a Promise.

Cleanup function

useEffect(() => {
  const interval = setInterval(() => {
    console.log('tick');
  }, 1000);

  return () => {
    clearInterval(interval);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

The cleanup function runs:

  • on unmount
  • before the next effect execution

useEffect as didUpdate

Single dependency

useEffect(() => {
  console.log('count changed');
}, [count]);
Enter fullscreen mode Exit fullscreen mode

The effect runs when count changes.

Multiple dependencies

useEffect(() => {
  console.log('filters changed');
}, [search, page, sort]);
Enter fullscreen mode Exit fullscreen mode

It runs whenever at least one dependency changes.

Object dependencies

This is where many real-world problems begin.

const filters = {
  search,
  page,
};

useEffect(() => {
  console.log('filters changed');
}, [filters]);
Enter fullscreen mode Exit fullscreen mode

This effect will run EVERY render.

Why?

Because the object is recreated every render.

Solution with useMemo

const filters = useMemo(() => {
  return {
    search,
    page,
  };
}, [search, page]);
Enter fullscreen mode Exit fullscreen mode

Now the reference stays stable.

useMemo: when to actually use it

useMemo is NOT for:

  • "optimizing React"
  • "avoiding renders"
  • "making everything faster"

It's for memoizing expensive values or stabilizing references.

Important: useMemo is a performance optimization hint, not a semantic guarantee. React may discard memoized values in some scenarios, especially during development, concurrent rendering, or future internal optimizations. Never depend on memoization for correctness—only for performance.

Correct use case

const sortedUsers = useMemo(() => {
  return [...users].sort((a, b) =>
    a.name.localeCompare(b.name)
  );
}, [users]);
Enter fullscreen mode Exit fullscreen mode

This makes sense because:

  • sorting may be expensive
  • the result gets reused

WRONG use case

const fullName = useMemo(() => {
  return `${name} ${surname}`;
}, [name, surname]);
Enter fullscreen mode Exit fullscreen mode

The cost here is irrelevant.

You added unnecessary complexity.

The problem with memoizing everything

Many modern React projects look like this:

useMemo(...)
useCallback(...)
useMemo(...)
useCallback(...)
Enter fullscreen mode Exit fullscreen mode

everywhere.

This often worsens:

  • readability
  • debugging
  • maintainability

And introduces mental overhead.

Important truth

If your code is properly split:

most of the time you do NOT need useMemo.

useCallback

useCallback memoizes a function.

const handleClick = useCallback(() => {
  console.log('click');
}, []);
Enter fullscreen mode Exit fullscreen mode

It is mainly useful when:

  • passing callbacks to memoized children
  • reference stability matters
  • the function is a dependency of an effect

The real difference between <Component /> and {component}

This is one of the least understood parts of React.

Many developers think:

<Child />
Enter fullscreen mode Exit fullscreen mode

and:

{child}
Enter fullscreen mode Exit fullscreen mode

are equivalent.

They are not.

The difference depends on WHERE the React element is created.

Case 1 — <Child /> inside render

function Child() {
  console.log('Child render');

  return <div>Child</div>;
}

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>

      <Child />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every update of Parent:

  • reruns Parent
  • recreates <Child />
  • React reevaluates the child

So:

Child render

appears on every click.

Case 2 — React element stabilized outside the component

const child = <Child />;

function Child() {
  console.log('Child render');

  return <div>Child</div>;
}

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>

      {child}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here:

  • child is created only once
  • the reference stays stable
  • React always receives the same element

So Child does NOT rerun every parent render.

Case 3 — Variable inside render

Many developers do this:

function Parent() {
  const child = <Child />;

  return <div>{child}</div>;
}
Enter fullscreen mode Exit fullscreen mode

But this changes NOTHING compared to:

<Child />
Enter fullscreen mode Exit fullscreen mode

Because:

const child = <Child />;
Enter fullscreen mode Exit fullscreen mode

runs again every render.

So:

  • new React element
  • new reference
  • new reconciliation

React element vs Component Function

This is the key concept.

This:

<Child />
Enter fullscreen mode Exit fullscreen mode

is NOT the component itself.

It's a React element.

An object similar to:

{
  type: Child,
  props: {}
}
Enter fullscreen mode Exit fullscreen mode

When React receives a new element:

  • it decides whether to render
  • compares the tree
  • performs reconciliation

Very common pattern: passing React elements as props

Many modern UI systems use this pattern:

function Layout({ sidebar }) {
  return (
    <div>
      <aside>{sidebar}</aside>
      <main>Content</main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const sidebar = <Sidebar />;

function App() {
  return <Layout sidebar={sidebar} />;
}
Enter fullscreen mode Exit fullscreen mode

Here sidebar remains stable.

If App rerenders:

  • React receives the same React element
  • Sidebar may avoid unnecessary rerenders

But be careful

Do NOT overuse this pattern.

Because it may:

  • hurt readability
  • create unexpected behavior
  • make the component tree less natural

Stabilizing React elements outside render is an advanced optimization pattern. In most applications, proper component splitting and state colocation are significantly more important.

Most of the time:

<Sidebar />
Enter fullscreen mode Exit fullscreen mode

is perfectly fine.

The real React optimization

Modern React optimization is about:

  • small components
  • localized state
  • stable props
  • simple architecture

Not:

  • useMemo everywhere
  • or const child = <Child /> everywhere

useReducer

useReducer is one of the most underrated React hooks.

It is NOT Redux

Many developers associate it with Redux.

But useReducer is simply:

  • local state management
  • reducer-based logic
  • excellent for complex flows

Basic syntax

const initialState = {
  count: 0,
};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {
        ...state,
        count: state.count + 1,
      };

    case 'decrement':
      return {
        ...state,
        count: state.count - 1,
      };

    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const [state, dispatch] = useReducer(
  reducer,
  initialState
);
Enter fullscreen mode Exit fullscreen mode

Dispatch

dispatch({ type: 'increment' });
Enter fullscreen mode Exit fullscreen mode

When to prefer useReducer

useState is perfect for simple state:

const [open, setOpen] = useState(false);
Enter fullscreen mode Exit fullscreen mode

But when you start having:

  • multiple related states
  • complex transitions
  • dependent updates
  • multi-step flows

useReducer becomes much clearer.

Real-world example: fetch state

const initialState = {
  loading: false,
  error: null,
  data: null,
};
Enter fullscreen mode Exit fullscreen mode

Reducer:

function reducer(state, action) {
  switch (action.type) {
    case 'fetch_start':
      return {
        ...state,
        loading: true,
      };

    case 'fetch_success':
      return {
        loading: false,
        data: action.payload,
        error: null,
      };

    case 'fetch_error':
      return {
        loading: false,
        data: null,
        error: action.payload,
      };

    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Here useReducer makes the flow:

  • more readable
  • more predictable
  • more scalable

React 19 & Beyond: Building on Fundamentals

React 19 introduces powerful new features. React 19.2 adds more. But they all build on the fundamentals covered above.

Server Actions (React 19)

Server Actions simplify async workflows through functions marked with 'use server':

'use server';

async function updateUser(formData) {
  const result = await db.users.update(formData);
  revalidatePath('/users');
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Used in a component:

function UserForm() {
  const [isPending, startTransition] = useTransition();

  return (
    <form action={updateUser}>
      <input name="email" required />
      <button type="submit" disabled={isPending}>
        Save
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why fundamentals apply: Even with Server Actions, you're managing state colocation (isPending stays local), understanding dependencies, and avoiding unnecessary re-renders (React handles submission state automatically).

useActionState & useOptimistic (React 19)

React 19 introduces useActionState for form submissions and useOptimistic for optimistic updates:

function Todo() {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(todos);

  const [formState, formAction] = useActionState(addTodoAction);

  return (
    <form action={async (formData) => {
      addOptimisticTodo(formData.get('todo'));
      await formAction(formData);
    }}>
      <input name="todo" />
      <button type="submit">Add</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why fundamentals apply: These APIs handle the complex state management patterns you'd normally need useReducer or useState for—but they're built on the same dependency and state management principles.

useEffectEvent (React 19.2)

useEffectEvent separates reactive from non-reactive logic in effects, formalizing a core concept:

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ theme is NOT a dependency
}
Enter fullscreen mode Exit fullscreen mode

Why fundamentals apply: This hook encodes a principle you already understand—effects depend on specific values (roomId matters), and non-reactive logic shouldn't trigger re-effects. Dependencies matter; this hook just makes it explicit.

<Activity /> (React 19.2)

The Activity component allows sections of the UI tree to be paused, hidden, and resumed independently:

<Activity mode={isVisible ? 'visible' : 'hidden'}>
  <Page />
</Activity>
Enter fullscreen mode Exit fullscreen mode

When hidden, Activity unmounts effects and defers updates. This pre-renders hidden sections without impacting visible performance.

Why fundamentals apply: Activity enables fine-grained control over which parts of the tree are active, following the same principle as state colocation—isolating state and rendering to where it matters.

Conclusion

Most modern React problems are not solved with:

  • more libraries
  • more abstractions
  • more custom hooks
  • more memoization

They are solved by returning to fundamentals.

Truly understanding:

  • when React renders
  • what changes by reference
  • how dependencies work
  • when to split components
  • when NOT to optimize
  • what referential stability means

is far more valuable than premature micro-optimizations.

Because most of the time, the best React performance does not come from useMemo.

It comes from simple architecture.


Remember: The best React developers are usually not the ones using the most hooks.

They are the ones building the simplest render trees.


Article Version: 2.1 (Updated October 2025 for React 19.2, with technical precision)

Top comments (0)