Calling .sort() or .push() Directly on React State Might Not Trigger a Re-render

typescript dev.to

This is an old, well-known React gotcha that's still genuinely common in real Next.js apps, specifically because the code that triggers it looks completely idiomatic JavaScript, and it works correctly in enough situations that the actual failure mode feels random and hard to pin down the first time you hit it.

The Setup That Looks Completely Normal

'use client';
import { useState } from 'react';

export function QueueList({ initialEntries }: { initialEntries: QueueEntry[] }) {
  const [entries, setEntries] = useState(initialEntries);

  function handleSort() {
    entries.sort((a, b) => a.priority - b.priority); // mutates in place
    setEntries(entries); // same array reference as before
  }

  return (
    <div>
      <button onClick={handleSort}>Sort by priority</button>
      {entries.map((entry) => (
        <div key={entry.id}>{entry.name}</div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Click the button, and often nothing visibly happens, even though the array genuinely did get sorted internally. The UI just doesn't reflect it, and there's no error anywhere to point at why.

Why This Happens

Array.prototype.sort() sorts the array in place and returns a reference to that same array, not a new one. entries.sort(...) mutates the existing array object directly, then setEntries(entries) passes React that exact same reference it already had. React's default change detection for state uses Object.is comparison, essentially asking "is this the same object as before." Since entries is literally the same array reference, just with its internal contents rearranged, React concludes nothing has changed and skips the re-render entirely, regardless of the fact that the actual order of items inside that array is genuinely different now.

Why This Feels Inconsistent Rather Than Reliably Broken

This is what makes it especially confusing to debug. If any other state update happens around the same time, a parent re-rendering for an unrelated reason, another piece of state changing in the same component, React might re-render anyway, for that unrelated reason, and the sorted array happens to display correctly as a side effect, not because the sort itself was recognized as a real state change. This makes the bug appear to work sometimes and fail other times, depending on what else happens to be triggering renders nearby, when the actual underlying cause, mutating state in place, is completely consistent and never actually changed.

The Same Issue With Other Common Array Methods

// All of these mutate in place and return the same reference,
// so setting state with the result changes nothing as far as React can tell

entries.push(newEntry);
setEntries(entries); // same reference

entries.splice(index, 1);
setEntries(entries); // same reference

entries.reverse();
setEntries(entries); // same reference
Enter fullscreen mode Exit fullscreen mode

push, splice, reverse, and sort all share this exact same trap, they mutate the array in place and return either the same array or an unrelated value, never a fresh, new array reference reflecting the change in a way React's default comparison can detect.

The Actual Fix: Always Create a New Array

function handleSort() {
  const sorted = [...entries].sort((a, b) => a.priority - b.priority);
  setEntries(sorted); // a genuinely new array reference
}

function handleAdd(newEntry: QueueEntry) {
  setEntries([...entries, newEntry]); // new array, not a mutated push
}

function handleRemove(id: string) {
  setEntries(entries.filter((entry) => entry.id !== id)); // filter always returns a new array
}
Enter fullscreen mode Exit fullscreen mode

[...entries].sort(...) spreads the existing array into a brand new one first, then sorts that new copy, leaving the original untouched and producing a genuinely different reference for React to correctly detect as a real change. filter, map, and the spread operator combined with concat all naturally produce new arrays rather than mutating in place, which is exactly why they're the generally recommended pattern for updating array state in React, not just a stylistic preference.

Why This Matters More With Server-Fetched Data Specifically

In a Next.js app, initial data often comes from a Server Component, passed down as a prop, then held in client-side state for interactive filtering, sorting, or reordering. This exact pattern, seed client state from server data, then let the user interact with it, is common enough that this specific mutation trap shows up constantly in dashboards, queues, and any interactive list, list, sort, filter functionality is one of the most common things built on top of server-fetched data displayed client-side.

A Quick Way to Catch This in Your Own Code

Search for direct calls to mutating array methods on state variables, specifically checking whether the result gets spread into something new or passed straight back into the setter as-is:

grep -n "\.sort(\|\.push(\|\.splice(\|\.reverse(" --include="*.tsx" -r components/
Enter fullscreen mode Exit fullscreen mode

For each match, check whether it's operating on a piece of React state, and whether the result is a genuinely new array or the same mutated reference being handed back to setState.

The Actual Rule

Never call a mutating array method directly on a piece of React state and pass the result straight back into its own setter. Spread into a new array first, or use a naturally non-mutating method, map, filter, concat, the spread operator, so every state update produces a genuinely new reference React can reliably detect as an actual change, rather than an in-place mutation that might or might not happen to trigger a re-render depending on unrelated things happening nearby.


If you've hit this exact "the button doesn't seem to do anything, except sometimes it does" confusion before, genuinely curious whether it turned out to be this, mutating state in place, or something else entirely. Drop what actually happened in the comments.

Get the templates: https://pixelanas.gumroad.com


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Source: dev.to

arrow_back Back to Tutorials