Back to topics

Functional Boundaries and State Management of Frontend Components

We all know that components should be pure, side-effect-free, and idempotent, so we should not introduce a store inside components.

By components, I mean: a button, a dialog modal, a form, a table.

These components should handle their own business: render the data passed in from the outside; emit events for various interactions to be handled externally.

For example:

  • The button's text and theme type should be passed in from the outside, and the click event should be emitted.

  • The dialog's content should be passed in from the outside, and the open and close events should be emitted.

  • The form's data model and values should be passed in from the outside, and the submit event should be emitted.

  • The data rendered in the table should be passed in from the outside, and various interaction events should be emitted.

This convention keeps my code layered clearly and works well. However, it also reveals maintenance difficulties:

  • Components receive more and more props.

  • While pages introduce these components, they also need to introduce more and more hooks themselves.

  • Components remain simple, but the page logic gradually becomes messy.

I want to make a simple analogy: components become children who never grow up, always waiting to be fed. And when pages introduce these components, they have to take care of these children.

container

Sometimes a component has zero props, but it can still solve your problem – that’s a good component.

This does not mean I want to indiscriminately inject a store into every component, making state used and mutated everywhere. Rather, there should be an intermediate state between pure functions and overuse of state management – a convention. It is neither as pure as a stateless component nor does it introduce as much processing logic as a page. Let’s call this kind of component a container.

Next, I will introduce two types of container: cohesive and composite.

Cohesive Container

A cohesive container is very similar to a class in object-oriented programming, both in design philosophy and implementation.
The difference is that for better testability and separation of concerns, we usually split it into a UI layer and a Hooks layer.

const useApplyForm = () => {
  // Form data
  const [formData, setFormData] = useState<ApplyFormData>({
    name: "",
    age: 0,
    email: "",
    description: ""
  });

  // Validation error messages
  const [errors, setErrors] = useState({});

  // Submission status
  const [isSubmitting, setIsSubmitting] = useState(false);

  // Form validation
  const validateForm = (): boolean => {
    ...
  };

  // Field update handler
  const handleFieldChange = (field: keyof ApplyFormData, value: string | number) => {
   ...
  };

  // Form submission handler
  const handleSubmit = async () => {
   ...
  };

  // Reset form
  const resetForm = () => {
    ...
  };

  return {
    ...
  };
};

export default useApplyForm;

const ApplyForm = ()=>{
  const {handleSubmit, handleFieldChange, formData} = useApplyForm()

  return <>
  	<Form>
  		<Input label="Name" value={formData.name} onChange={handleFieldChange}></Input>
    	<Input label="Age" value={formData.age} onChange={handleFieldChange}></Input>
      ...
    	<Button onClick={handleSubmit}>Submit</Button>
  	</Form>
  </>
}

Composite Container

A composite container is a collection of stateless components.

For example:

Consider a feature-rich table page where each row has several action buttons. Clicking these buttons triggers corresponding dialogs. The page code might look like this:

const TablePage = (props) => {
  const { tableData, handleClick, flagA, flagB } = useTable();

  return (
    <>
      <Table data={tableData}>
        <ButtonA onClick={() => (flagA = true)}></ButtonA>
        <ButtonB onClick={() => (flagB = true)}></ButtonB>
        ...
      </Table>
      <DialogA show={flagA}></DialogA>
      <DialogB show={flagB}></DialogB>
      ...
    </>
  );
};

As you can imagine, as features increase, the number of button click event callbacks grows, components like DialogA multiply, and states like flagA proliferate.

At this point, a simple classification is in order: DialogGroup and TableContainer – one for all dialogs, the other for the collection of buttons.

const DialogGroup = () => {
  const { flagA, flagB } = useDialogStore();

  return (
    <>
      <DialogA show={flagA}></DialogA>
      <DialogB show={flagB}></DialogB>
      ...
    </>
  );
};

const TableContainer = () => {
  const { flagA, flagB } = useDialogStore();
  const { tableData } = useTable();

  return (
    <Table data={tableData}>
      <ButtonA onClick={() => (flagA = true)}></ButtonA>
      <ButtonB onClick={() => (flagB = true)}></ButtonB>
      ...
    </Table>
  );
};

The previously hard-to-maintain table page code becomes this – concise and easy to maintain.

const TablePage = (props) => {
  return (
    <>
      <TableContainer />
      <DialogGroup />
    </>
  );
};

Summary

In real projects, I recommend creating a dedicated containers directory to store these components. This is not only a physical separation but also a logical distinction: the components directory holds pure components, the containers directory holds business containers, and the pages directory organizes page-level components.

For naming, we can name pure components by their function (e.g., Button, Dialog) and containers by their business domain (e.g., UserModule, OrderFlow). This naming convention clearly reflects the component's responsibility.

The Container pattern allows us to maintain the purity of basic components while better organizing business logic. The key is to strike the right balance of responsibility boundaries – neither making containers too complex nor over-splitting in pursuit of "purity". In practice, we need to choose the right approach based on the specific scenario, making the code both maintainable and easy to understand.