When working on React applications, deeply nested components can quickly become difficult to manage. Data flow, event handling, and validation can create unnecessary complexity if not structured properly.

In this article, we’ll explore an architectural pattern that ensures scalability, maintainability, and clarity when working with nested components. This pattern follows the single responsibility principle, keeping child components focused on their own logic while the parent component handles all external operations.

📌 Want to see it live? Check out the 👉 Demo

📌 Want to explore the full project? Check out the 👉 GitHub Repository

🏗️ The Problem with Deeply Nested Components
As React applications grow, components tend to become deeply nested. This often leads to:

Tightly coupled components — Child components handle operations they shouldn’t.
Difficult debugging — Business logic is scattered across multiple components.
Reduced reusability — Components become difficult to extract and reuse.

🎯 The Goal
We want to:

✅ Keep child components pure (only concerned with UI and internal state).
✅ Centralize logic in the parent component (handling external actions like saving to a database).
✅ Use a validation utility to ensure data integrity before saving.
🛠️ Implementing the Pattern
Parent Component: Handling All Operations
The parent component is responsible for: 1️⃣ Managing application state. 2️⃣ Validating data before saving. 3️⃣ Handling updates from child components.

Parent component

import { useCallback, useState } from "react";
import { toast } from "react-toastify";
import { Data } from "../data/Data";
import { Child1 } from "./child1";
import { Child2 } from "./child2";


export const Parent = () => {
  const [data, setData] = useState<Data | undefined>({
    child1: undefined,
    child2: undefined,
    grandChild: undefined,
  });

const isDataComplete = useCallback(
    (incomingData: Partial<Data> | undefined): incomingData is Data => {
      return (
        !!incomingData?.child1 &&
        incomingData?.child1.trim().length > 0 &&
        !!incomingData?.child2 &&
        !!incomingData?.grandChild
      );
    },
    []
  );

const onSave = useCallback(
    (data: Partial<Data> | undefined) => {
      if (!isDataComplete(data)) {
        toast("Please fill all fields first", { style: { color: "black", backgroundColor: "#f9b6af" } });
        return;
      }
      toast("You filled all your fields!", { style: { color: "black", backgroundColor: "lightgreen" } });
    },
    [isDataComplete]
  );

const onUpdate = useCallback(
    (incomingData: Partial<Data>) => {
      setData(prev => ({ ...prev, ...incomingData }));
    },
    []
  );

return (
    <>
      <Child1 data={data} onUpdate={onUpdate} />
      <Child2 data={data} onUpdate={onUpdate} onSave={onSave} />
    </>
  );
};

Child1 Component: Delegating Updates

The child component only manages its own input and delegates updates to the parent.

import { useState } from "react";
import { Data } from "../data/Data";

export const Child1 = ({ data, onUpdate }: { data: Data | undefined; onUpdate: (parentData: Partial<Data>) => void; }) => {
  const [child1Input, setChild1Input] = useState(data?.child1);

return (
    <>
      <label>Child 1 input</label>
      <input
        value={child1Input}
        onChange={(e) => {
          setChild1Input(e.target.value);
          onUpdate({ child1: e.target.value });
        }}
      />
    </>
  );
};

Child2 Component: Nested Child and Save Action

import { useState } from "react";
import { GrandChild } from "./grandChild";

export const Child2 = ({ data, onUpdate, onSave }) => {
  const [child2Input, setChild2Input] = useState(data?.child2);

return (
    <>
      <label>Child 2 input</label>
      <input
        value={child2Input}
        onChange={(e) => {
          setChild2Input(e.target.value);
          onUpdate({ child2: e.target.value });
        }}
      />
      <GrandChild onUpdate={onUpdate} data={data} onSave={onSave} />
    </>
  );
};

GrandChild Component: Uses delegated logic for button operation

import { useState } from "react";

export const GrandChild = ({ onUpdate, data, onSave }) => {
  const [grandChildInput, setGrandChildInput] = useState(data?.grandChild);

return (
    <>
      <label>Grandchild input</label>
      <input
        value={grandChildInput}
        onChange={(e) => {
          setGrandChildInput(e.target.value);
          onUpdate({ grandChild: e.target.value });
        }}
      />
      <button onClick={() => onSave(data)}>Save</button>
    </>
  );
};

Custom logic and parent logic

If a child or grandchild component needs to include custom logic before updating the parent, it can define a custom callback that applies its own logic first and then invokes the delegated parent operation. For example:

const customOnUpdate = (value: string, onUpdate: (data: Partial<Data>) => void) => {
  console.log("Custom logic before updating:", value);

  // Then execute parent logic
  onUpdate();

console.log("Custom logic after updating:", transformedValue);
};

🎯 Why This Works
✅ Separation of concerns — Child components handle only their own logic.
✅ Single source of truth — The parent component manages and validates data.
✅ Improved reusability — Any component can be reused independently.
✅ Better maintainability — Debugging and extending functionality is easier.

🚀 Happy Coding!