跪拜 Guibai
← Back to the summary

The Model–useEffect Split That Keeps React Data Flows Sane

Writing a color picker in a React page isn't difficult — just prepare three numbers to represent RGB. Requesting a set of member data isn't complicated either: call an async function inside the component and render the results as a table.

The real questions to think about are:

This article uses React + TypeScript to implement an RGB color display and an async member list, focusing on two key concepts: using a model to unify data structures, and using useEffect to handle side effects outside of component rendering.

Why Create a Separate Model

Data on the frontend page is usually not used by just one function.

RGB color data passes through:

App State
   ↓
ColorPicker
   ↓
ColorBrowser

Member data passes through:

memberApi
   ↓
MemberTable State
   ↓
MemberRow

If every file redeclares the data structure, it not only creates duplicate code but also easily leads to inconsistencies when fields change. Therefore, you can centrally define the data models used on the frontend in a model directory:

src
├── model
│   ├── color.ts
│   └── member.ts
├── api
│   └── memberApi.ts
└── components
    ├── ColorBrowser.tsx
    ├── ColorPicker.tsx
    └── MemberTable.tsx

The Model is not responsible for rendering the UI or requesting interfaces. It answers only one question: what structure should this type of data have.

Describing RGB Data with Color

An RGB color consists of three channels: red, green, and blue. Therefore, define Color in model/color.ts:

export interface Color {
  red: number
  green: number
  blue: number
}

This interface stipulates that a valid Color object must contain all three numeric fields.

In App.tsx, the color State directly uses this model:

import { useState } from 'react'
import { type Color } from './model/color'

const [color, setColor] = useState<Color>({
  red: 20,
  green: 200,
  blue: 180,
})

useState<Color> connects the State to the data model. The initial value must conform to Color, and any new state passed to setColor later must also contain red, green, and blue.

If a field is missing, TypeScript will directly prompt that the type is incomplete:

// Missing blue, cannot be used as a complete Color
setColor({
  red: 100,
  green: 200,
})

Here, the code uses:

import { type Color } from './model/color'

The type keyword indicates that the imported Color is only used for type checking, not as a variable or function to call at runtime.

Reusing the Same Model Across Multiple Components

The color preview component receives Color via Props:

import * as React from 'react'
import { type Color } from '../model/color'

interface Props {
  color: Color
}

const ColorBrowser: React.FC<Props> = (props) => {
  const divStyle: React.CSSProperties = {
    height: '7rem',
    width: '11rem',
    backgroundColor: `rgb(${props.color.red},${props.color.green},${props.color.blue})`,
  }

  return <div style={divStyle} />
}

The color editing component also uses Color, while stipulating that the update function must receive a new color object:

interface Props {
  color: Color
  onColorUpdated: (color: Color) => void
}

App passes the same state to the two components separately:

<ColorBrowser color={color} />
<ColorPicker color={color} onColorUpdated={setColor} />

ColorBrowser reads the color and generates a background color, while ColorPicker is responsible for producing a new color. Although the two components have different responsibilities, their understanding of the color structure comes from the same Model.

This is the direct value of an independent model directory: when data enters State, Props, and component callbacks, it uses the same type contract.

Model Constrains Structure, Components Guarantee Value Ranges

The README stipulates that the value range for RGB is 0 to 255. But the field types in Color are just number:

red: number
green: number
blue: number

Therefore, Color can guarantee that the three channels are numbers, but it cannot independently guarantee that the numbers are within the valid RGB range.

The current page restricts user operations via range input fields:

<input
  type="range"
  min="0"
  max="255"
  value={props.color.red}
/>

The Model describes the data structure, while the component is responsible for the input boundaries in the current interaction. They solve problems at different levels.

Preserving the Complete Model When Updating Object State

When the user drags the red slider, only red needs to be updated, but the new state must still be a complete Color:

onChange={event => props.onColorUpdated({
  ...props.color,
  red: +event.target.value,
})}

...props.color first copies the original three channels, then overwrites red with the new value. This preserves green and blue while creating a new color object.

The update methods for green and blue are the same:

props.onColorUpdated({
  ...props.color,
  green: +event.target.value,
})
props.onColorUpdated({
  ...props.color,
  blue: +event.target.value,
})

event.target.value is a string, so the code uses the unary plus operator to convert it to a number, satisfying the field type requirements in Color.

The color selection part illustrates how the Model constrains local interaction state. Next, let's see how the Model runs through async interfaces and component rendering.

Unifying Interface Data with MemberEntity

Member data is defined in model/member.ts:

export interface MemberEntity {
  id: number
  login: string
  avatar_url: string
}

It stipulates that a member object contains a numeric id, and string-type login and avatar_url.

The API layer imports this model and declares that the async function ultimately returns a member array:

import { type MemberEntity } from '../model/member'

export const getMemberCollection = (): Promise<MemberEntity[]> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        {
          id: 1457912,
          login: 'brauliodiez',
          avatar_url: 'https://avatars.githubusercontent.com/u/1457912?v=3',
        },
        {
          id: 4374977,
          login: 'Nasdan',
          avatar_url: 'https://avatars.githubusercontent.com/u/4374977?v=3',
        },
      ])
    }, 500)
  })
}

The return type is:

Promise<MemberEntity[]>

It expresses two pieces of information:

  1. The data is not returned immediately, but asynchronously via a Promise;
  2. What is obtained after the Promise completes is a MemberEntity array.

The api directory is responsible for providing data, and the model directory is responsible for describing the data. This way, when a component calls the interface, it doesn't need to re-guess what fields are in the returned object.

What Are Side Effects in useEffect

The main responsibility of a React component is to calculate the UI based on Props and State. For example, ColorBrowser calculates the background color based on color, which is normal rendering logic.

But operations like requesting data, setting timers, and subscribing to events interact with systems outside the component and cannot be described solely through JSX results. These types of operations are usually called side effects.

The member table needs to call getMemberCollection() after the component is displayed. This async call is not part of the table structure itself, so it is placed inside useEffect for execution:

React.useEffect(() => {
  (async () => {
    const members = await getMemberCollection()
    setMemberCollection(members)
  })()
}, [])

This code reflects the basic responsibility of useEffect: after the component completes a render, execute the logic that needs to synchronize with an external data source.

Why Not Write the Request Directly in the Component Function

React function components re-execute when the state updates. If the request is written directly in the component function body:

getMemberCollection().then((members) => {
  setMemberCollection(members)
})

After the request returns, it updates the State, the State update triggers the component to re-execute, and when the component re-executes, it might request data again. Data fetching and rendering would trigger each other.

useEffect separates side effects from rendering calculations:

React.useEffect(() => {
  // Request member data here
}, [])

The current code passes an empty dependency array [], indicating that this Effect does not depend on any Props or State that might change within the component, and is used to initiate this data load after the component mounts.

Why Use an Async Immediately Invoked Function Inside the Effect

The data request needs await:

const members = await getMemberCollection()

The code does not directly declare the Effect callback as async. Instead, it creates and immediately executes an async function inside the Effect:

React.useEffect(() => {
  (async () => {
    const members = await getMemberCollection()
    setMemberCollection(members)
  })()
}, [])

The execution process can be broken down into four steps:

MemberTable initial render
        ↓
useEffect executes the async function
        ↓
Wait for getMemberCollection() to return the member array
        ↓
setMemberCollection(members) updates State

Here, the async immediately invoked function is responsible for waiting for the Promise, and useEffect is responsible for deciding that this side effect should execute after the component renders.

Receiving API Data with Array State

MemberTable uses MemberEntity[] to store the member list:

const [MemberCollection, setMemberCollection] =
  React.useState<MemberEntity[]>([])

The initial value is an empty array. When the component renders for the first time, there are no member rows in tbody, but the table itself can already be displayed.

After the API returns data:

setMemberCollection(members)

The member array enters State, React re-executes the component based on the new state, and then renders the table rows via map:

<tbody>
  {MemberCollection.map((member: MemberEntity) => (
    <MemberRow key={member.id} member={member} />
  ))}
</tbody>

The Props of MemberRow are also constrained by the Model:

const MemberRow = (props: { member: MemberEntity }) => {
  const { member } = props

  return (
    <tr>
      <td>
        <img
          src={member.avatar_url}
          style={{ maxWidth: '10rem' }}
        />
      </td>
      <td>{member.id}</td>
      <td>{member.login}</td>
    </tr>
  )
}

From the API return value to State, and then to child component Props, the entire process consistently uses MemberEntity. This ensures that every layer in the async data flow adheres to the same structural contract.

How Model and useEffect Work Together

Model and useEffect do not solve the same problem, but they meet in async data handling.

The Model is responsible for answering:

What does the data look like?

useEffect is responsible for answering:

When should data be fetched and synchronized to the component?

The complete flow for the member list is as follows:

MemberEntity defines the member structure
          ↓
memberApi declares Promise<MemberEntity[]>
          ↓
useEffect calls the API after the component mounts
          ↓
useState<MemberEntity[]> saves the result
          ↓
MemberRow renders data according to MemberEntity

If there is only a Model without useEffect, the component knows the data structure but lacks a suitable timing to load the async data.

If there is only useEffect without a Model, the data shapes between the interface, State, and child components lack a unified constraint.

When the two are combined, async data has both a clear loading process and a type contract that runs through all layers.

Summary

In React + TypeScript, model is not just a directory for storing interface declarations. It is responsible for establishing a unified structure for data that spans components and modules.

Color data is connected through Color:

useState<Color>
→ ColorPicker Props
→ onColorUpdated(Color)
→ ColorBrowser Props

Member data is connected through MemberEntity:

Promise<MemberEntity[]>
→ useEffect async request
→ useState<MemberEntity[]>
→ MemberRow Props

And the focus of useEffect is not just "execute once after the component mounts." It is used to separate side effects like data requests from rendering logic, fetch external data at the appropriate time, and then drive page updates through State.

After understanding the cooperation between Model and useEffect, when facing real interfaces and more complex component trees, you can more clearly judge: where the data structure is defined, when the async operation executes, and how the returned data safely enters the page.