You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React + TypeScript 中父子组件同时维护状态时避免渲染循环的最佳实践

Solution for Infinite Render Loop in React + TypeScript Parent-Child Component State Sync

Let's break down the core problem first: you've got duplicate state in both parent and child components, and the way you're syncing them via useEffect creates a loop—child state changes trigger parent updates, which flow back to the child, which triggers more parent updates, and so on. Plus, your initial render was incorrectly firing callbacks without user action.

Here's a clean, standard solution that fixes all these issues while supporting both controlled (parent-managed state) and uncontrolled (child-managed state) use cases, plus nested component scenarios:

Step 1: Refactor the Child Component to Distinguish State Sources

The key fix is separating state changes initiated by the user (which should notify the parent) from state changes triggered by prop updates (which should not notify the parent). We'll use helper functions for user-initiated changes and sync props to internal state without triggering callbacks.

Child Component (IBaseComponent.tsx)

import React, { useState, useEffect } from 'react';
import isEqual from 'lodash.isEqual'; // Or use a custom deep compare function

interface IBaseComponentProps {
  records: { id: string; label: string[] }[];
  recordID: string;
  onChange?: (newRecords: typeof props.records) => void;
  onSelect?: (id: string) => void;
}

const IBaseComponent: React.FC<IBaseComponentProps> = (props) => {
  // Initialize internal state with parent props
  const [internalRecordID, setInternalRecordID] = useState<string>(props.recordID);
  const [internalRecords, setInternalRecords] = useState<typeof props.records>(props.records);

  // Helper functions for user-initiated state changes (triggers parent callbacks)
  const updateSelectedRecord = (newID: string) => {
    setInternalRecordID(newID);
    props.onSelect?.(newID); // Notify parent only when user acts
  };

  const updateRecordList = (newRecords: typeof props.records) => {
    setInternalRecords(newRecords);
    props.onChange?.(newRecords); // Notify parent only when user acts
  };

  // Sync internal state with parent props (NO callbacks)
  useEffect(() => {
    if (props.recordID !== internalRecordID) {
      setInternalRecordID(props.recordID);
    }
  }, [props.recordID, internalRecordID]);

  useEffect(() => {
    // Use deep compare for arrays/objects to avoid unnecessary syncs
    if (!isEqual(props.records, internalRecords)) {
      setInternalRecords(props.records);
    }
  }, [props.records, internalRecords]);

  // Example user interaction handlers (use the helper functions)
  const handleUserSelect = (id: string) => {
    updateSelectedRecord(id);
  };

  const handleUserDeleteRecord = (id: string) => {
    const updatedRecords = internalRecords.filter(record => record.id !== id);
    updateRecordList(updatedRecords);
    
    // Auto-select first record if deleted record was selected
    if (internalRecordID === id) {
      updateSelectedRecord(updatedRecords[0]?.id || '');
    }
  };

  // Render logic
  return (
    <div>
      <ul>
        {internalRecords.map(record => (
          <li 
            key={record.id} 
            onClick={() => handleUserSelect(record.id)}
            style={{ fontWeight: internalRecordID === record.id ? 'bold' : 'normal' }}
          >
            {record.label.join(', ')}
            <button onClick={() => handleUserDeleteRecord(record.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default IBaseComponent;

Parent Component (IParentComponent.tsx)

import React, { useState, useEffect } from 'react';
import IBaseComponent from './IBaseComponent';

const IParentComponent: React.FC = () => {
  const [recordID, setRecordID] = useState<string>('');
  const [records, setRecords] = useState<{ id: string; label: string[] }[]>([]);

  const handleChange = (newRecords: typeof records) => {
    setRecords(newRecords);
  };

  const handleSelect = (id: string) => {
    setRecordID(id);
  };

  // Initialize sample data
  useEffect(() => {
    setRecords([
      { id: '1', label: ['Apple', 'Red'] },
      { id: '2', label: ['Banana', 'Yellow'] },
      { id: '3', label: ['Grape', 'Purple'] },
    ]);
  }, []);

  return (
    <div>
      <IBaseComponent
        recordID={recordID}
        records={records}
        onChange={handleChange}
        onSelect={handleSelect}
      />
      <p>Selected Record ID: {recordID}</p>
    </div>
  );
};

export default IParentComponent;

Key Fixes & Explanations

  1. Separate State Update Logic:

    • Helper functions (updateSelectedRecord, updateRecordList) handle user-initiated changes and immediately notify the parent. This eliminates code duplication while ensuring callbacks only fire when the user acts.
    • Prop sync useEffect hooks update internal state without triggering callbacks, breaking the infinite loop.
  2. Deep Comparison for Arrays:

    • Using isEqual (or a custom deep compare) ensures we only sync records when the actual content changes, not just when the array reference changes (which happens frequently in React).
  3. No Initial Render Callback Spam:

    • Callbacks are only triggered by user actions, so your initial render won't fire onChange or onSelect unnecessarily.
  4. Supports All Scenarios:

    • Controlled Mode: Parent manages state, child syncs and notifies on user actions.
    • Uncontrolled Mode: Use the child without parent props—it will manage its own state and still trigger callbacks if provided.
    • Nested Components: Works seamlessly through multiple parent layers, as each parent passes down props and handles callbacks normally.

Optional Improvements

  • Custom Deep Compare: If you don't want to use lodash, implement a simple deep compare for your records type (e.g., check length and compare each record's id and label arrays).
  • Memoize Parent Props: Use useMemo for records in the parent if it's derived from other state to reduce unnecessary child re-renders.
  • Default Props: Add default values to the child's props so it can be used without parent input (e.g., empty array for records, empty string for recordID).

内容的提问来源于stack exchange,提问作者Zhihar

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.27 09:42:38