Controlled Components in ReactJS

Controlled components in ReactJS are an essential concept for managing form inputs and ensuring that your application’s state is always in sync with the user interface. Over the years, playing around with various different technologies trying to manage and control the view of the HTML in the browser in products and service i’ve build, I have found controlled components (in Reactjs) to be a powerful tool for creating predictable and maintainable forms in web applications. In this article, I will share insights from my personal experience on understanding and effectively using controlled components in ReactJS and why it’s so robust and easy to maintain and use.

What Are Controlled Components?

In React, a controlled component is an input element (such as <input>, <textarea>, or <select>) whose value is controlled by the state of a React component. This means that the value of the input field is driven by the component’s state and updated via React’s state management rather than directly from the DOM. In simple terms, in basic HTML the component default and future values are base on the browser or the code, those values are “hardcoded” and when user interact with them they are not saved but they are only visual presentation, With controlled components the data is saved, temporary in the browser memory and it can be used by the developer behind the scene to submit the data to a more persistent data source like Mongodb, Mysql via an API.

Why Use Controlled Components?

Using controlled components ensures that the React component has full control over the form inputs, leading to a single source of truth for form data. In addition it create a smooth User experience and allow the product or service that we build to be more interactive and easier to use, the best example is in when customer fill a form input and we want to tells the customer that the input provided is not valid(password not strong enough or user name is too short or too long etc..) This approach offers several benefits from engineering point of view:

  1. Predictability: The input value is always synced with the component’s state, making it easier to manage and predict the behavior of the form.
  2. Validation: You can easily implement form validation by updating the state based on user input and providing feedback in real-time (as we disccused above).
  3. Integration: Controlled components integrate seamlessly with other React features like context, hooks, and state management libraries.
  4. Persistent data: data that is controlled within react temporary state, can be saved for longer via api on our own database and be used at later stages.

Basic Example of Controlled Components

Here’s a simple example demonstrating how to use controlled components in a React form:

import React, { useState } from 'react';

function ControlledForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  const handleNameChange = (event) => {
    setName(event.target.value);
  };

  const handleEmailChange = (event) => {
    setEmail(event.target.value);
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log('Form submitted:', { name, email });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input type="text" value={name} onChange={handleNameChange} />
      </label>
      <label>
        Email:
        <input type="email" value={email} onChange={handleEmailChange} />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}

export default ControlledForm;

In this example, the name and email inputs are controlled by the component’s state. The handleNameChange and handleEmailChange functions update the state when the input values change, ensuring that the component’s state is always in sync with the user input.

Handling Multiple Inputs
For forms with multiple inputs, you can manage state by using an object to store the form data, However this approach have downside and upsides:

import React, { useState } from 'react';

function ControlledForm() {
  const [formData, setFormData] = useState({ name: '', email: '' });

  const handleChange = (event) => {
    const { name, value } = event.target;
    setFormData((prevFormData) => ({
      ...prevFormData,
      [name]: value,
    }));
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log('Form submitted:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input type="text" name="name" value={formData.name} onChange={handleChange} />
      </label>
      <label>
        Email:
        <input type="email" name="email" value={formData.email} onChange={handleChange} />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}

export default ControlledForm;

In this example, the handleChange function updates the state for multiple inputs by using the name attribute of the input elements. However as we previously mention there are pros and cons for each approach, let’s take a look at the following table to better understand each approach.

AspectUsing an Object with Multiple ParametersUsing Multiple useState Hooks
State InitializationSingle useState call with an object containing multiple properties.Multiple useState calls, each for a specific piece of state.
State UpdateUpdate using a single setState function, often with spread syntax to maintain other properties.Update each state separately using individual setState functions.
Code SimplicitySimplifies state declaration and initialization with a single line.More verbose as each state requires its own useState declaration and initialization.
PerformanceCan trigger a re-render for all properties even if only one changes, potentially leading to unnecessary re-renders.Can be more performant as only the specific piece of state that changes will trigger a re-render.
ReadabilityCan become harder to read and manage as the number of properties in the object increases.Generally easier to read and manage individual pieces of state, especially with a clear naming convention.
Complexity of UpdatesRequires careful handling to update nested properties without overwriting the entire state object (e.g., using spread operator).Each piece of state is updated independently, reducing the risk of accidentally overwriting other state values.
DebuggingEasier to track and debug as all related state is in one place.Can be easier to debug specific state changes due to isolated updates.
Type SafetyMay require more robust typing (e.g., with TypeScript) to ensure all properties are correctly managed.TypeScript can easily manage and infer types for individual state pieces, making it clearer and safer.
Form HandlingConvenient for handling form inputs where many fields can be grouped into a single state object.Each form field might need its own useState and handler, making form handling more verbose but sometimes clearer.
MemoizationHarder to memoize and optimize updates for individual properties within the state object.Easier to memoize and optimize specific state updates with hooks like useMemo and useCallback.
State ResetEasier to reset the entire state to initial values by resetting the object.Each piece of state would need to be reset individually, which can be more verbose but precise.
Component StructureMight be preferable in simpler components with logically grouped state.Preferable in complex components where state management can become unwieldy if using a single object.

Best Practices for Controlled Components
Debounce Input Changes: For performance optimization, especially in large forms, debounce the input changes to reduce the number of state updates. It is more crucial when we attach the controlled input to an event that is fired high number of times, like onChange or onKeyPress etc.

import { useState, useCallback } from 'react';
import {debounce} from 'lodash';

const debouncedHandleChange = useCallback(debounce(handleChange, 300), []);

Below is a list of events that are fired often and may require debounce, It is a best practice as a software engineer to simply log each triggered event and understand how they works:

EventDescriptionReason for Frequent Firing
inputFired whenever the value of an <input>, <textarea>, or <select> element has changed.Fires on every keystroke, paste, or any input method, leading to frequent updates especially during typing.
keyupFired when a key is released after being pressed.Fires every time a key is released, when a user type text into input it can trigger the event often.
keydownFired when a key is pressed down.Fires every time a key is pressed down, also leading to frequent triggering during continuous typing.
keypressFired when a key that produces a character value is pressed down.Fires repeatedly as the key is held down, especially during fast or continuous typing.
mousemoveFired when the pointer is moved over an element.Fires continuously as the mouse is moved, generating many events in a short period of time.
scrollFired when the document view or an element is scrolled.Fires repeatedly as the user scrolls, particularly on continuous or fast scrolling.
resizeFired when the document view (window) is resized.Fires continuously as the window is resized, generating many events during the resize action.

Validation: Implement real-time validation within the handleChange function or in the handleSubmit function to provide immediate feedback to users. Meaning that you don’t necessary want to trigger all validations in your forms (because we might have 50 different validations), however it’s a small performance impact and in many cases we will prefer to provide immediate feedback over the expense of performance.

const handleChange = (event) => {
  const { name, value } = event.target;
  if (name === 'email' && !isValidEmail(value)) {
    // Show validation error
  } else {
    setFormData((prevFormData) => ({
      ...prevFormData,
      [name]: value,
    }));
  }
};

Component Composition: Break down large forms into smaller, reusable components to improve readability and maintainability.

function TextInput({ label, name, value, onChange }) {
  return (
    <label>
      {label}
      <input type="text" name={name} value={value} onChange={onChange} />
    </label>
  );
}

function ControlledForm() {
  const [formData, handleChange] = useForm({ name: '', email: '' });

  return (
    <form>
      <TextInput label="Name" name="name" value={formData.name} onChange={handleChange} />
      <TextInput label="Email" name="email" value={formData.email} onChange={handleChange} />
      <button type="submit">Submit</button>
    </form>
  );
}

Above example can be splitted to two different approach, the one you see above which is to split the code to smaller components that are techincal (“TextInput” is technical) and another approach would be is to split the code into product base component and the name would be “NameInput” and “EmailInput” rather than “TextInput” – this approach is less reusable but more readable since we write the code base on the product and cut it down to smaller parts and smaller files leads to easier maintainability.

Conclusion
Controlled components are a fundamental concept in ReactJS, enabling developers to create reliable and interactive forms. By managing the input state within React components, you can maintain a single source of truth for form data, implement real-time validation, and ensure a predictable user experience. Following best practices such as debouncing input changes, using custom hooks, and breaking down forms into smaller components can further enhance the maintainability and performance of your React applications. Through my experience, mastering controlled components has proven invaluable in building robust and user-friendly web applications.

Leave a Reply

Your email address will not be published. Required fields are marked *

All rights reserved 2024 ©