Steve Kinney

Component Granularity Splitting

Component splitting is React performance optimization’s double-edged sword. Split too little and you get monolithic components that re-render everything when a single prop changes. Split too much and you end up with component confetti—dozens of tiny pieces scattered across your codebase that make debugging feel like archaeological excavation. The secret is finding that sweet spot where each component has a single responsibility and clear boundaries, making your app both fast and maintainable.

Why Component Granularity Matters

Every time a component re-renders in React, it potentially triggers a cascade of child re-renders. When you have a large component managing multiple pieces of state, changing any single piece can cause the entire component tree to re-render unnecessarily. This becomes particularly painful when you have:

  • Complex forms with multiple input fields
  • Lists with interactive items
  • Dashboard-style layouts with multiple widgets
  • Shopping carts with item quantities and totals

The goal isn’t to create the most components possible—it’s to create components that change together, stay together.

The Monolithic Component Problem

Let’s start with a classic example: a product card that displays too much and does too much.

// ❌ Monolithic component - everything re-renders when anything changes
function ProductCard({ product }: { product: Product }) {
  const [quantity, setQuantity] = useState(1);
  const [isWishlisted, setIsWishlisted] = useState(false);
  const [showDetails, setShowDetails] = useState(false);
  const [reviews, setReviews] = useState<Review[]>([]);
  const [isLoadingReviews, setIsLoadingReviews] = useState(false);

  // This effect runs on every render
  useEffect(() => {
    if (showDetails && reviews.length === 0) {
      setIsLoadingReviews(true);
      fetchReviews(product.id).then(setReviews);
    }
  }, [showDetails, reviews.length, product.id]);

  const handleQuantityChange = (newQuantity: number) => {
    setQuantity(newQuantity);
    // Changing quantity re-renders the entire card, including reviews
  };

  const toggleWishlist = () => {
    setIsWishlisted(!isWishlisted);
    // Wishlist toggle re-renders everything
  };

  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p className="price">${product.price}</p>

      {/* Quantity selector */}
      <div>
        <button onClick={() => handleQuantityChange(quantity - 1)}>-</button>
        <span>{quantity}</span>
        <button onClick={() => handleQuantityChange(quantity + 1)}>+</button>
      </div>

      {/* Wishlist button */}
      <button onClick={toggleWishlist}>{isWishlisted ? '❤️' : '🤍'}</button>

      {/* Details toggle */}
      <button onClick={() => setShowDetails(!showDetails)}>
        {showDetails ? 'Hide' : 'Show'} Details
      </button>

      {/* Expensive reviews section */}
      {showDetails && (
        <div>
          {isLoadingReviews ? (
            <div>Loading reviews...</div>
          ) : (
            reviews.map((review) => (
              <div key={review.id} className="review">
                <strong>{review.author}</strong>
                <p>{review.content}</p>
                <div>{''.repeat(review.rating)}</div>
              </div>
            ))
          )}
        </div>
      )}
    </div>
  );
}

This component has too many responsibilities. When the quantity changes, the entire card re-renders—including the reviews section, which might contain dozens of review components. When you toggle the wishlist, the quantity selector re-renders unnecessarily.

Strategic Component Splitting

The key is to split components along state boundaries. Each piece of independent state should generally live in its own component (or at least be isolated from unrelated state).

// ✅ Split into focused components
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="product-card">
      <ProductImage src={product.image} alt={product.name} />
      <ProductInfo name={product.name} price={product.price} />
      <ProductActions productId={product.id} />
    </div>
  );
}

function ProductActions({ productId }: { productId: string }) {
  return (
    <div className="product-actions">
      <QuantitySelector />
      <WishlistButton productId={productId} />
      <ProductDetails productId={productId} />
    </div>
  );
}

function QuantitySelector() {
  const [quantity, setQuantity] = useState(1);

  // Only this component re-renders when quantity changes
  return (
    <div className="quantity-selector">
      <button onClick={() => setQuantity((q) => q - 1)}>-</button>
      <span>{quantity}</span>
      <button onClick={() => setQuantity((q) => q + 1)}>+</button>
    </div>
  );
}

function WishlistButton({ productId }: { productId: string }) {
  const [isWishlisted, setIsWishlisted] = useState(false);

  // Only this component re-renders when wishlist status changes
  return (
    <button onClick={() => setIsWishlisted(!isWishlisted)}>{isWishlisted ? '❤️' : '🤍'}</button>
  );
}

function ProductDetails({ productId }: { productId: string }) {
  const [showDetails, setShowDetails] = useState(false);

  return (
    <>
      <button onClick={() => setShowDetails(!showDetails)}>
        {showDetails ? 'Hide' : 'Show'} Details
      </button>
      {showDetails && <ReviewsList productId={productId} />}
    </>
  );
}

function ReviewsList({ productId }: { productId: string }) {
  const [reviews, setReviews] = useState<Review[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetchReviews(productId).then((reviews) => {
      setReviews(reviews);
      setIsLoading(false);
    });
  }, [productId]);

  if (isLoading) return <div>Loading reviews...</div>;

  return (
    <div className="reviews-list">
      {reviews.map((review) => (
        <ReviewItem key={review.id} review={review} />
      ))}
    </div>
  );
}

// Individual review items are memoized to prevent unnecessary re-renders
const ReviewItem = memo(({ review }: { review: Review }) => (
  <div className="review">
    <strong>{review.author}</strong>
    <p>{review.content}</p>
    <div>{''.repeat(review.rating)}</div>
  </div>
));

Now when you change the quantity, only the QuantitySelector re-renders. When you toggle the wishlist, only the WishlistButton re-renders. The expensive reviews list only renders when needed and never re-renders due to unrelated state changes.

The Art of Choosing Split Points

Not every piece of JSX needs its own component. Here’s how to identify good split points:

Split When State is Independent

// ✅ Good split - each piece of state is independent
function UserProfile() {
  return (
    <div>
      <ProfilePicture /> {/* Has its own upload state */}
      <ContactInfo /> {/* Has its own editing state */}
      <PreferencesList /> {/* Has its own selection state */}
    </div>
  );
}

// ❌ Don't split purely for organization without performance benefit
function UserProfile() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  return (
    <div>
      {/* These don't need to be separate components */}
      <NameField name={name} onChange={setName} />
      <EmailField email={email} onChange={setEmail} />
    </div>
  );
}

Split When Components Get Complex

Use the “scroll test”—if you can’t see the entire component on your screen at once, it’s probably doing too much.

// ✅ Break down complex forms
function CheckoutForm() {
  return (
    <form>
      <ShippingAddress />
      <PaymentMethod />
      <OrderSummary />
      <PromoCodeInput />
    </form>
  );
}

// Each section manages its own validation and state
function ShippingAddress() {
  const [address, setAddress] = useState(initialAddress);
  const [errors, setErrors] = useState({});

  // Complex validation logic contained here
  const validateAddress = useCallback(() => {
    // ... validation logic
  }, [address]);

  return <fieldset>{/* Address form fields */}</fieldset>;
}

Split When You Need Different Update Frequencies

Some parts of your UI update frequently while others remain static. Separate them to avoid unnecessary work.

// ✅ Separate frequently updating components
function TradingDashboard() {
  return (
    <div className="dashboard">
      <StaticHeader /> {/* Never changes */}
      <LivePriceTracker /> {/* Updates every second */}
      <UserPortfolio /> {/* Updates when user trades */}
      <StaticFooter /> {/* Never changes */}
    </div>
  );
}

const StaticHeader = memo(() => (
  <header>
    <h1>Trading Dashboard</h1>
    <nav>{/* Navigation links */}</nav>
  </header>
));

function LivePriceTracker() {
  const prices = useWebSocket('/api/prices');

  return (
    <div className="price-grid">
      {prices.map((price) => (
        <PriceCard key={price.symbol} price={price} />
      ))}
    </div>
  );
}

Common Splitting Pitfalls

Over-Splitting: The Component Confetti Problem

// ❌ Over-split - creates unnecessary complexity
function BlogPost({ post }: { post: Post }) {
  return (
    <article>
      <PostTitle title={post.title} />
      <PostDate date={post.date} />
      <PostAuthor author={post.author} />
      <PostContent content={post.content} />
      <PostTags tags={post.tags} />
    </article>
  );
}

// ✅ Better - only split where it provides value
function BlogPost({ post }: { post: Post }) {
  return (
    <article>
      <header>
        <h1>{post.title}</h1>
        <div className="meta">
          <time>{post.date}</time>
          <span>by {post.author}</span>
        </div>
      </header>
      <PostContent content={post.content} /> {/* Split because it's complex */}
      <TagList tags={post.tags} /> {/* Split because it's interactive */}
    </article>
  );
}

Prop Drilling From Over-Splitting

When you split components too aggressively, you might create prop-drilling problems. Consider using context or state management libraries for deeply shared state.

// ❌ Creates prop drilling
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  return (
    <Layout user={user} theme={theme}>
      <Sidebar user={user} theme={theme}>
        <Navigation user={user} theme={theme} />
      </Sidebar>
      <Main user={user} theme={theme}>
        <Content user={user} theme={theme} />
      </Main>
    </Layout>
  );
}

// ✅ Use context for widely shared state
const AppContext = createContext();

function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  return (
    <AppContext.Provider value={{ user, theme, setUser, setTheme }}>
      <Layout>
        <Sidebar>
          <Navigation />
        </Sidebar>
        <Main>
          <Content />
        </Main>
      </Layout>
    </AppContext.Provider>
  );
}

Testing Split Components

One major benefit of proper component splitting is improved testability. Smaller, focused components are easier to test in isolation.

// ✅ Easy to test in isolation
import { render, fireEvent, screen } from '@testing-library/react';
import { QuantitySelector } from './QuantitySelector';

test('increments quantity when plus button is clicked', () => {
  render(<QuantitySelector />);

  const incrementButton = screen.getByText('+');
  const quantity = screen.getByText('1');

  fireEvent.click(incrementButton);

  expect(screen.getByText('2')).toBeInTheDocument();
});

test('decrements quantity when minus button is clicked', () => {
  render(<QuantitySelector />);

  const incrementButton = screen.getByText('+');
  const decrementButton = screen.getByText('-');

  fireEvent.click(incrementButton); // Go to 2
  fireEvent.click(decrementButton); // Back to 1

  expect(screen.getByText('1')).toBeInTheDocument();
});

Compare this to testing a monolithic component where you’d need to set up the entire product context just to test quantity logic.

Performance Monitoring

Use React DevTools Profiler to measure the impact of your component splits. Look for:

  • Reduced render time for individual components
  • Fewer unnecessary re-renders when state changes
  • Smaller commit phases in the profiler
// Add display names for easier profiling
QuantitySelector.displayName = 'QuantitySelector';
WishlistButton.displayName = 'WishlistButton';
ReviewsList.displayName = 'ReviewsList';

[!TIP]
Profile before and after splitting to ensure you’re actually improving performance, not just moving complexity around.

When NOT to Split

Sometimes keeping components together is the right choice:

  • Tightly coupled logic that always changes together
  • Simple, static content that never re-renders
  • Components smaller than 50 lines with single responsibility
  • When splitting would create complex prop passing
// ✅ Keep simple, coupled logic together
function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});

  // Email and password validation are tightly coupled
  const handleSubmit = (e) => {
    e.preventDefault();
    const newErrors = validateLoginForm({ email, password });
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }
    // Submit login
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        error={errors.email}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        error={errors.password}
      />
      <button type="submit">Login</button>
    </form>
  );
}

Real-World Splitting Strategy

Here’s a practical approach for splitting existing components:

  1. Identify state boundaries - Group related state together
  2. Find expensive operations - Isolate heavy computations or API calls
  3. Look for independent features - Things that can work standalone
  4. Consider user interaction patterns - What gets clicked/changed together?
  5. Profile and measure - Verify that splits actually improve performance

The goal is components that are focused, testable, and performant—not the maximum number of components possible. Every split should have a clear justification: either performance, maintainability, or reusability.

Last modified on .