跪拜 Guibai
← Back to the summary

The Three Iron Laws of React and TypeScript at Scale

Using the Pyramid Principle to break down three refactorings of a React + TS project, revealing the underlying logic of component design evolution.


I. Core Conclusion (Top of the Pyramid)

React + TypeScript is the golden combination for enterprise-level frontend development. The key to using it well is not memorizing APIs, but understanding three iron rules: type constraints guarantee compile-time safety, unidirectional data flow guarantees state correctness, and single-responsibility components guarantee maintainability.

The specific implementation paths for these three iron rules are:

  1. Type Constraints: Use React.FC<Props> + interface to establish a "contract" for component communication, intercepting errors at compile time.
  2. Unidirectional Data Flow: Lift state up to the parent component, pass it down via props, and notify upwards via callbacks—this is the "law" of React applications.
  3. Single-Responsibility Components: Strive for UI = fn(props). Child components are only responsible for display and do not hold business state.

Below, the SCQA framework is used to unfold the entire derivation process.


II. Preface: Why Get on the TypeScript Bandwagon

2.1 Situation

React is already the mainstream frontend framework. The popularity of Hooks has made function components the standard, and TypeScript's penetration rate in domestic enterprise projects has exceeded 70%.

When writing React projects, you will likely encounter these scenarios:

2.2 Conflict

JavaScript's freedom is a double-edged sword. It's flexible for small projects, but dangerous for large ones:

// This is perfectly legal in JS, but will explode at runtime
<HelloComponent userName={123} />  // Should have passed a string, actually passed a number
<NameEditComponent onNameUpdated="not a function" />  // Callback passed as a string

Code without type constraints is like an intersection without traffic lights—small cars (small projects) can pass through with tacit understanding, but heavy traffic (large teams) will inevitably crash.

2.3 Question

How can we enjoy React's flexibility while obtaining the stability and maintainability required for enterprise-level projects?

2.4 Answer

React + TypeScript, combined with three core practices: type constraints for safety, unidirectional data flow for correctness, and single-responsibility components for maintainability.

Now, let's expand layer by layer from the top down.


III. Key Point Layer 1: Type Constraints—Creating a "Contract" for Component Communication

3.1 What is React.FC

Opening the React source code, the definition of FC is just one line:

type FC<P = {}> = FunctionComponent<P>;

What does this mean? As long as you write React.FC<Props>, the TypeScript compiler automatically checks:

  1. Whether the props you pass to the component satisfy the Props definition.
  2. Whether the component returns a valid React node.

3.2 The Props Interface: The "Contract" Between Parent and Child

In practice, a Hello component is used as an example—its function is simple, to greet someone:

// Hello.tsx
import * as React from 'react';

interface Props {
  userName: string;
}

const Hello: React.FC<Props> = (props) => {
  return <h1>Hello {props.userName}!</h1>;
};

Don't underestimate these 5 lines of code. The role interface Props plays here is a "technical contract" between parent and child components:

Role Meaning of the Contract
Parent Component "I will give you a string called userName"
Child Component "I guarantee to receive a userName and will only ever display it, never change it"
Compiler "I've noted what you both said. I'll flag an error if anyone breaches the contract"

If you write <Hello userName={123} /> in the parent component, the editor will show an error before the code even runs. This is the core value of type constraints: moving runtime errors to compile time.

3.3 Custom Events: Constraining Not Just Data, but Behavior

Components don't just display data; they also respond to user actions. In React, a child component notifies its parent through callback functions—and the signatures of these callback functions also need type constraints.

Take NameEditComponent as an example; it has an action for "submitting a new name":

// NameEditComponent2.tsx
interface Props {
  initialUserName: string;
  onNameUpdated: (newName: string) => void;  // The contract for the callback signature
}

Here, onNameUpdated: (newName: string) => void tells the parent component:

When the parent component uses it:

<NameEditComponent
  initialUserName={username}
  onNameUpdated={setUserName}  // setUserName's signature is exactly (val: string) => void, types match!
/>

The setUserName returned by useState has the exact signature (value: string) => void, perfectly matching onNameUpdated—this is not a coincidence; it's the type system performing parameter-level interface verification for you.

3.4 React Synthetic Events: Types in a Native Guise

Form controls have onChange. The code looks like a native DOM event, but it's actually a React synthetic event:

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setEditingName(e.target.value);
};

The generic React.ChangeEvent<HTMLInputElement>:

The complexity of the event (type inference, target type) is locked inside the child component. The parent component only needs to know that "the child component will give me a string," without caring whether this string comes from e.target.value or elsewhere. This is the essence of encapsulation.

Summary: The essence of type constraints is not "writing a few more type annotations," but turning the implicit contracts between components into explicit ones. The compiler is a 24/7 Code Reviewer.


IV. Key Point Layer 2: Unidirectional Data Flow—The "Constitution" of React Applications

4.1 What is Unidirectional Data Flow

React has an iron rule: Data can only flow from parent components to child components, never the other way around.

Parent Component (holds state + methods to modify state)
    │
    │  props (data down)
    ▼
Child A    Child B    Child C
(read-only) (read-only) (read-only)
    │           │           │
    └───────────┴───────────┘
          Callbacks (notification up)

4.2 Where Should State Go

This is the most tangled question for React beginners. There is only one criterion:

Does this state need to be shared by multiple child components?

  • Yes → Lift it up to their nearest common parent component.
  • No → Keep it inside the child component as private state.

In our example:

// App.tsx (Parent Component)
const App = () => {
  const [name, setName] = React.useState<string>("defaultUserName");  // Shared state
  const [editingName, setEditingName] = React.useState("defaultUserName");  // Shared state

  // ✅ Parent component holds the method to modify state
  const setUserNameState = () => {
    setName(editingName);
  };

  return (
    <>
      Name: {name}
      <HelloComponent userName={name} />
      <NameEditComponent
        initialUserName={name}
        editingName={editingName}
        onNameChange={setUserNameState}
        onEditingNameUpdated={setEditingName}
      />
    </>
  );
};

name is used by two child components (the display component HelloComponent and the edit component NameEditComponent) simultaneously → it must be lifted to the parent component. This is a prerequisite for application state correctness, with no room for compromise.

4.3 Why It's a "Law" and Not a "Suggestion"

If you violate unidirectional data flow—for example, a child component directly modifies the props passed down from the parent, or multiple child components each maintain their own copy of the same data—bugs will appear in even slightly complex interactions:

The essence of unidirectional data flow: There is always only one "source of truth" for data (the parent component's state). Anyone wanting to change it must go through the "legal channel" (callback) provided by the parent. This is why it's called React's "constitution"—it's not that you can't bypass it, but chaos ensues the moment you do.


V. Key Point Layer 3: The Three Evolutions of Component Design—From "Usable" to "Elegant"

This is the most valuable practical part of this article. We'll look at how the same NameEditComponent underwent three refactorings, each solving a specific design problem.

5.1 Version 1: Passing the Event Object to the Parent Component (❌ Responsibility Leak)

// Version 1: Child Component
interface Props {
  username: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

const NameEditComponent: React.FC<Props> = (props) => {
  return (
    <div>
      <label>Update Name:</label>
      <input value={props.username} onChange={props.onChange} />
    </div>
  );
};

// Parent component is forced to write this kind of code:
const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
  setUserName(event.target.value);
};

What's the problem?

The child component "leaks" the complex type React.ChangeEvent<HTMLInputElement> to the parent component. The parent is forced to:

  1. Import the React.ChangeEvent type.
  2. Know that "I am getting the value from an <input>."
  3. Write implementation details like event.target.value.

Moreover, both the parent and child components write React.ChangeEvent<HTMLInputElement>—one implementation detail, declared twice, with zero reuse.

Core Problem: The child component exposes "how I implement the interaction" (using <input> + onChange), instead of telling the parent "what value I can provide" (I can let the user edit a name and ultimately give you a string).

5.2 Version 2: Child Component Digests the Event Itself (✅ Encapsulating Complexity)

// NameEditComponent2.tsx (Version 2)
interface Props {
  initialUserName: string;
  onNameUpdated: (newName: string) => void;  // Note: Changed to pass a string!
}

const NameEditComponent: React.FC<Props> = (props) => {
  const [editingName, setEditingName] = React.useState(props.initialUserName);

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEditingName(e.target.value);  // Event complexity is locked inside the child component
  };

  const onNameSubmit = () => {
    props.onNameUpdated(editingName);  // On submit, only give the final value to the parent
  };

  return (
    <>
      <label>Update name:</label>
      <input value={editingName} onChange={onChange} />
      <button onClick={onNameSubmit}>Update</button>
    </>
  );
};

What changed?

Dimension Version 1 Version 2
Parent callback signature (e: ChangeEvent<HTMLInputElement>) => void (newName: string) => void
Event handling location Parent manually does e.target.value Digested inside child component
Types parent needs to import React.ChangeEvent None
Submit button None (notifies parent on every keystroke) Yes (submits only after user confirmation)
Edit intermediate state None (directly modifies props) Yes (child's private state editingName)

Core Improvements:

  1. Encapsulating Complexity: React.ChangeEvent<HTMLInputElement> stays inside the child component. The parent only needs to know "I will receive a string."
  2. Adding a Confirmation Button: The user edits and clicks "Confirm" to submit, no longer triggering a parent update on every keystroke.
  3. Edit Intermediate State: The child component uses private state editingName to manage the value during editing, without directly modifying props.

The key idea of this version: Let the parent component return to its original mission—holding state and methods to modify state, and sharing them with child components. Don't make the parent care about "how the child implements the interaction."

5.3 Version 3 (Ultimate Form): Stateless Child Component—UI = fn(props)

// Ultimate Version: All state lifted to the parent component
// Parent Component
const App = () => {
  const [name, setName] = React.useState<string>("defaultUserName");
  const [editingName, setEditingName] = React.useState("defaultUserName");

  return (
    <>
      Name: {name}
      <HelloComponent userName={name} />
      <NameEditComponent
        initialUserName={name}
        editingName={editingName}
        onNameChange={setUserNameState}
        onEditingNameUpdated={setEditingName}
      />
    </>
  );
};

// Child Component (pure display + pure callbacks)
interface Props {
  initialUserName: string;
  editingName: string;
  onNameChange: () => void;
  onEditingNameUpdated: (newName: string) => void;
  disabled: boolean;
}

The philosophy of this version is: Lift all state to the parent component, turning the child component into a "pure function"—give it props, it returns UI, and that's it.

UI = fn(props)

Why is this a "better" design?

  1. Better Performance: Stateless child components can be memo-optimized more aggressively by React.
  2. Easier Testing: Pure function components have deterministic inputs and outputs, without depending on any hidden internal state.
  3. Absolutely Single Responsibility: The child component does only one thing—render. It doesn't need to care where data comes from or where it goes.
  4. Debug-Friendly: All state is in the parent component. You can see the entire data flow at a glance in React DevTools.

5.4 The Underlying Logic of the Three Evolutions

Version 1: Usable, but responsibility leaks
  ↓ Problem: Parent is forced to know child's implementation details (ChangeEvent)
  
Version 2: Encapsulates complexity, child digests events itself
  ↓ Problem: State is scattered between parent and child, not pure enough

Version 3: State converges to parent, child = pure display
  ↓ Goal: UI = fn(props)

This evolution path wasn't pre-planned but was pushed step-by-step by problems. Understanding these three steps means understanding why production React code in large companies looks the way it does.


VI. Key Point Layer 4: useEffect—Managing Lifecycles within "Side Effects"

6.1 What are Side Effects

React's core is "rendering state into UI." Anything outside this rendering process is a side effect. This includes:

6.2 Three Execution Timings

The second parameter of useEffect (the dependency array) controls when it executes:

// 👇 Executes only once after mount (simulates componentDidMount)
React.useEffect(() => {
  loaderUsername();  // Async data loading
}, []);

// 👇 Executes after mount + every time `todos` changes
React.useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

// 👇 Executes after mount + after every re-render (⚠️ Use with caution)
React.useEffect(() => {
  console.log('Component updated');
});  // No second argument passed
Dependency Array Execution Timing Suitable Scenarios
[] Executes only once after mount Initialization requests, mounting event listeners
[todos] After mount + after todos changes Syncing state to localStorage, re-fetching based on prop changes
Not passed After every render Rarely used, easy to cause infinite loops

6.3 Core Principle: Render First, Work Later

React.useEffect(() => {
  loaderUsername();  // ← Second priority: Executes only after mount is complete
}, []);

// Component returns JSX   ← First priority: Render quickly

React's philosophy: The component's first priority is to display the interface quickly, making the user feel it's fast. As for "slow work" like requesting data or operating on storage, put it in useEffect to do asynchronously. From the user's perspective, the order is:

  1. The interface appears first (possibly showing a loading state or initial value).
  2. When the data arrives, the interface updates.

This is the secret to React's "speed"—not letting side effects block the initial render.

6.4 Cleaning Up Side Effects: Don't Leave Memory Garbage

React.useEffect(() => {
  const timer = setInterval(() => {
    console.log('Polling...');
  }, 1000);

  return () => {  // ← Executes before unmounting
    clearInterval(timer);  // Clear the timer
  };
}, []);

The function returned by return executes before the component unmounts. If you don't clear timers, event listeners, and subscriptions, even after the component is removed from the DOM, the timer is still running, and the occupied memory can never be reclaimed—this is a classic case of a frontend memory leak.

6.5 Practical Use: Implementing a One-Click Triple Combo with useEffect

const App = () => {
  const [name, setName] = React.useState<string>("defaultUserName");
  const [editingName, setEditingName] = React.useState("defaultUserName");

  // Async data loading after mount
  const loaderUsername = () => {
    setTimeout(() => {
      setName("name from async call");
      setEditingName("name from async call");
    }, 2000);
  };

  React.useEffect(() => {
    loaderUsername();  // Component renders first, data arrives 2 seconds later
  }, []);

  // Sync to localStorage when name changes
  React.useEffect(() => {
    localStorage.setItem('username', name);
  }, [name]);

  return (/* ... */);
};

Two useEffect calls, each with its own responsibility:

Each useEffect does only one thing. This is a best practice for Hook design—much clearer than piling unrelated logic into componentDidMount in class components.


VII. Summary: Returning to the Top of the Pyramid

We used the pyramid structure to break down the core knowledge system of React + TypeScript enterprise development. Let's review the entire chain of reasoning:

React + TypeScript Enterprise Development = Type Constraints + Unidirectional Data Flow + Component Design + Side Effect Management
                                   │
           ┌───────────────────────┼───────────────────────┐
           │                       │                       │
    Type Constraints (Contract) Unidirectional Data Flow (Constitution) Component Design (Evolution)
    ├─ React.FC<Props>       ├─ State Lifting               ├─ V1: Responsibility Leak
    ├─ interface Props       ├─ Props Down             ├─ V2: Encapsulate Complexity
    ├─ Callback Signature Constraint           ├─ Callbacks Up               └─ V3: UI = fn(props)
    └─ Synthetic Event Encapsulation           └─ Single Source of Truth
                                   
                          Side Effect Management
                          ├─ After Mount: Async Loading
                          ├─ After Update: Sync Storage
                          └─ Before Unmount: Clean Up Resources

The Three Iron Rules, Re-emphasized

Iron Rule One-Sentence Explanation
Type Constraints Turn implicit contracts into explicit ones; the compiler does your Code Review.
Unidirectional Data Flow Data always flows from top to bottom; there is only one source of truth.
Single-Responsibility Components The less a child component does, the better. The ultimate goal is UI = fn(props).

Three Sentences You Can Take Away

  1. Write the interface before writing props: This isn't a hassle; it's leaving a way out for yourself and your colleagues.
  2. Unsure where to put state? Lift it up: Putting it in a common parent component is never wrong.
  3. Things in useEffect can always wait: Render first, work later. What the user perceives is the real performance.