Summary

Hello all! I’m having issues reconciling props and state when rendering a list of server components that pass props to client components. Specifically, when the order of the list of rendered components on the server changes, it appears that React is not reconciling the change in order on the client, resulting in a mismatch between server-obtained props and client-side state. Could I get some help/insight on to why this occurs, how to fix it, and/or best practices?

Code:

Here is some representative code from my app (CodeSandbox here):

The relevant files are app/page.tsx and app/person-details-dialog.tsx. In Home(), I’m using an array of Person objects to render a list of <PersonCard /> (server) components, each unique to a Person object and receiving a unique key (person.id). In my app, the people array is fetched from Supabase DB, which does not always return the same order of people after edits are made. Here, I replicate that by randomizing people before rendering the list; I know this is bad practice for a real app, but this is just to represent that the order of the rendered list may change when fresh data is fetched.

Each PersonCard() component passes its person object as a prop across the server-client boundary to a <PersonDetailsDialog /> client component.

Structure of the component tree:

<Home /> (server)
 ├-- <PersonCard person=Abby />  (server)
 │    ├- <PersonDetailsDialog person=Abby /> (client)
 ├-- <PersonCard person=Brian /> (server)
 │    ├- <PersonDetailsDialog person=Brian /> (client)
 ├-- <PersonCard person=Chris /> (server)
 │    ├- <PersonDetailsDialog person=Chris /> (client)
// app/page.tsx
// ...
export interface Person {
  id: number;
  name: string;
}

function PersonCard({ person }: { person: Person }) {
  return (
    <div className="m-4 w-72 min-w-72 flex-none rounded border-2 p-3 shadow">
      <h3 className="mt-3 text-2xl font-semibold">{person.name}</h3>
      <PersonDetailsDialog person={person} />
    </div>
  );
}

export default function Home() {
  const people: Person[] = [
    { id: 1, name: "Abby" },
    { id: 2, name: "Brian" },
    { id: 3, name: "Chris" },
  ];

  // Shuffling the list of people simulates change in order of rows coming from Supabase db query
  const shuffleArray = (array: Person[]) => {
    for (let i = array.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      const temp = array[i];
      array[i] = array[j];
      array[j] = temp;
    }
  };
  shuffleArray(people);

  return (
    <div className="flex flex-wrap justify-center">
      {people?.map((person) => <PersonCard key={person.id} person={person} />)}
    </div>
  );
}

The PersonDetailsDialog() (client) component initializes a name state variable from its person prop, for use in a controlled input. The “Save Changes” button presumably updates the database with the new name value (just logged here for simplicity), then uses router.refresh() to refresh the current route and obtain fresh data in the Home() server component. Again, when Home() refreshes, the order of the people array is randomized to reflect that the data received from the database can change order.

// app/person-details-dialog.ts
"use client";
// ...
export default function PersonDetailsDialog({ person }: { person: Person }) {
  const router = useRouter();

  const [name, setName] = useState(person.name);

  const onSubmit = () => {
    // Database update would be made here
    console.log(`Saved your changes to ${person.name}`);
    // Use router.refresh() to refresh the current route and obtain fresh data in the server components
    router.refresh();
  };

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button className="mt-3 w-full">Learn More</Button>
      </DialogTrigger>
      <DialogContent className="max-h-screen overflow-y-auto sm:max-w-[600px]">
        <DialogHeader>
          <DialogTitle>{person.name}</DialogTitle>
        </DialogHeader>
        <Input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        <Button onClick={onSubmit} className="ml-1 mr-1 flex-auto">
          Save Changes
        </Button>
      </DialogContent>
    </Dialog>
  );
}

Bug/problem:

However, a change in order of the rendered PeopleCard() components results in a mismatch between the props passed from PeopleCard() to PeopleDetailsDialog() and the client-side state of PeopleDetailsDialog(). You can observe this in the sandbox by opening any dialog and clicking Save Changes repeatedly. The order of PeopleCard() components changes as you can see in the background, but the state of the PeopleDetailsDialog() (its open/closed state and the value of the controlled name input) persists and is not updated to reflect this change. This creates a mismatch between props and state: for instance, if the order of the rendered PeopleCard() components changes from Abby, Brian, Chris to Chris, Brian, Abby, now the first PeopleDetailsDialog() component will receive Chris as a prop but keep Abby‘s state from before. (You can see this in the value of the rendered <DialogTitle>{person.name}</DialogTitle> in PersonDetailsDialog().

Expected functionality

Why does this mismatch happen? Each rendered <PersonCard /> on the server already receives a unique key={person.id}, so I would expect that React would recognize the changed order of PersonCard subtrees in the final JSX render tree and match the correct props to the corresponding state in each PersonDetailsDialog(), while preserving the client-side state.

When clicking “Save Changes” to trigger router.refresh(), the currently-open PersonDetailsDialog() should remain open and should receive the same Person prop as before, regardless of the change in order of the rendered PersonCard parent components.

This functionality works as expected when working strictly with client components; React is able to reconcile a change in order of rendered parent components, while maintaining the correct correspondence to child props and preserving child state. To prove this, I created a variant of the above code (sandbox) where all relevant components are client components. The people array is now stored in a state variable in Home() (it would be fetched from the database via subscription/snapshot in the real app), and I simulate the change in the order of people with a callback function that is prop-drilled down to PersonDetailsDialog().

// app/page.tsx
"use client";
// ...
export interface Person {
  id: number;
  name: string;
}

function PersonCard({ person, callback }: { person: Person; callback: () => void }) {
  return (
    <div className="m-4 w-72 min-w-72 flex-none rounded border-2 p-3 shadow">
      <h3 className="mt-3 text-2xl font-semibold">{person.name}</h3>
      <PersonDetailsDialog person={person} callback={callback} />
    </div>
  );
}

export default function Home() {
  const [people, setPeople] = useState([
    { id: 1, name: "Abby" },
    { id: 2, name: "Brian" },
    { id: 3, name: "Chris" },
  ]);

  // Shuffling the list of people simulates change in order of rows coming from Supabase db query
  const shufflePeople = () => {
    const shuffledPeople = people
      .map((value) => ({ value, sort: Math.random() }))
      .sort((a, b) => a.sort - b.sort)
      .map(({ value }) => value);
    setPeople(shuffledPeople);
  };

  return (
    <div className="flex flex-wrap justify-center">
      {people?.map((person) => <PersonCard key={person.id} person={person} callback={shufflePeople} />)}
    </div>
  );
}
// app/person-details-dialog.tsx
"use client";
// ...
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { type Person } from "./page";

export default function PersonDetailsDialog({
  person,
  callback,
}: {
  person: Person;
  callback: () => void;
}) {
  const [name, setName] = useState(person.name);

  const onSubmit = () => {
    console.log(`Saved your changes to ${person.name}`);
    callback();
  };

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button className="mt-3 w-full">Learn More</Button>
      </DialogTrigger>
      <DialogContent className="max-h-screen overflow-y-auto sm:max-w-[600px]">
        <DialogHeader>
          <DialogTitle>{person.name}</DialogTitle>
        </DialogHeader>
        <Input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        <Button onClick={onSubmit} className="ml-1 mr-1 flex-auto">
          Save Changes
        </Button>
      </DialogContent>
    </Dialog>
  );
}

This variant displays the correct/expected behavior; even if the order of PeopleCard() components changes in the background, the state of PeopleDetailsDialog() persists (modal remains open) and the right props are always passed to PeopleDetailsDialog(). It seems the state-prop-mismatch bug from earlier is specific to lists rendered on the server and passing props across the server-client boundary to stateful client components.

My questions

1. Why does reconciliation of rendered lists work differently in server + client components versus strictly client components?

2. When the order of the list of rendered components on the server changes, is there way to address the mismatch between server-obtained props and client-side state, while preserving client-side state? What is the best/easiest approach?

I would like to modify my code to match the functionality of the client-only variant, such that a change in order of PeopleCard() components persists the state of PeopleDetailsDialog() while maintaining a correct matching of state to props. (i.e. the modal should remain open, and the props received by the currently open modal should not change.)

Some solutions I’ve considered:

1. Adding a unique key prop (key={person.id}) to <PersonDetailsDialog /> inside of PersonCard().
This partially works in that it prevents the props-state mismatch described earlier, but also causes React to reset the state of PersonDetailsDialog(). The dialog state resets to closed when clicking “Save Changes” causes a change in order of PersonCard() components, since a <PersonDetailsDialog /> with a given key is now being rendered at a different position in the render tree. I’m also not fully satisfied with this solution because I don’t understand why a unique key prop is needed for <PersonDetailsDialog /> to prevent props-state mismatch in my original server + client example, but not when utilizing only client components.

2. Maintaining a consistent sorting order in the array fetched from the database
This is somewhat avoiding the problem, as I am planning to implement functionality to toggle sorting of the data obtained from my database, i.e. alphabetically. There is always potential for the order of the data array to change, for instance if I am sorting alphabetically by name and then edit a person’s name to occupy a different position in the list. I need to avoid a mismatch between props and state in all scenarios.

Thank you so much in advance for all your guidance! I am using this app to teach RSCs and Next.js 13 to beginner devs, so any help would be super helpful to both me and several other aspiring webdevs.

New contributor

Matthew Su is a new contributor to this site. Take care in asking for clarification, commenting, and answering.
Check out our Code of Conduct.