DEV Community

SebasQuiroga
SebasQuiroga

Posted on

Managing Deeply Nested React Components: An Architectural Approach πŸ€“

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} />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 });
        }}
      />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

🎯 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!

Top comments (0)