© 2024 Jeremiah Yee

Building React Reusable Components Part I: Controlled and Uncontrolled

17 Nov 2024

It’s been awhile since I’ve written a post! Anyway, I’ve gone back and did some practicing over the past year, and wanted to share my learnings about React here. This will be a three-part series about how to write reusable components that your colleagues or other developers can use. We typically build components for the interface – but sometimes, the “users” of our components might not just be the end-users, but it could be other developers as well!

A pre-note: I’m super open to feedback! So if you spot anything that sounds iffy or have other suggestions to improve the learnings and examples, do drop me a message. And let me know if you’d like to see other topics covered here.

Background

When building reusable components in React, it’s essential to provide flexibility to the developers who will use them. One way to achieve this is by allowing your components to be used in controlled, uncontrolled, or semi-controlled modes. In this article, we’ll explore what these terms mean and how to implement them in your components. We’ll follow a concrete example throughout to illustrate each concept clearly.

Understanding Controlled vs. Uncontrolled Components

Before diving into the code, let’s clarify what we mean by controlled, uncontrolled, and semi-controlled.

Controlled Components

Components where the parent component fully manages the state. The component receives its current value and an update callback via props. Think about passing value and onChange to form inputs – in those cases, you are controlling the component.

Uncontrolled Components

Components that manage their own state internally. The parent doesn’t directly control the component’s state. Even if you don’t provide value or onChange to Input, it can still behave normally and allow the user to type in the textbox. This is useful when the user doesn’t really need to know about the input’s value, only what happens when the value is changed. In that case, they can provide onChange without value, and the input component will default to using its internal value to manage its own state.

Semi-Controlled Components

There’s an in-between mode, which we can call semi-controlled components. These are components that manage their own state but expose methods and values to the parent via refs or instances. Think about how in Antd forms, you can pass a FormInstance to a Form component, and use methods like getFieldValue, resetFields, etc. If you wanted to reset the form without these imperative methods, you would have to write your own reset function in the parent, which adds bloat and complexity – this behavior is already provided in the Form, and therefore you have the right to use it.

When to Use

In most cases, it would be good to have a mix of all three. They’re not mutually exclusive. Most components start out uncontrolled and provide a way to control it from the parent. When developing reusable or common components for other developers to use, this is a necessity.

A semi-controlled mode is optional but provides immense flexibility. Different parts of an application may have varying requirements:

  • Controlled Mode is perfect for complex forms where validation and state synchronization are crucial.
  • Uncontrolled Mode suits simpler forms or scenarios where state management can be deferred until necessary.
  • Semi-Controlled Mode offers a middle ground, providing the ability to interact with components programmatically without the overhead of full state management.

By supporting all three modes, your components become more adaptable, allowing developers to choose the most appropriate approach based on the specific needs of their application. This versatility not only enhances the reusability of your components but also simplifies integration across diverse use cases.

Example: Building a CustomInput Component

Let’s dive into an example. We’ll create a CustomInput component that can function in all three modes. This input will display its current value and allow users to type into it. Additionally, we’ll add functionality to reset its value programmatically when used in semi-controlled mode.

1. Uncontrolled CustomInput

Typically, you start building a reusable component in uncontrolled mode, to ensure that everything works as intended without external intervention to its state. In uncontrolled mode, the CustomInput manages its own state. The parent component doesn’t control its value directly.


import React, { useState, InputHTMLAttributes } from 'react';

interface CustomInputProps extends InputHTMLAttributes<HTMLInputElement> {
  defaultValue?: string;
  placeholder?: string;
}

const CustomInput: React.FC<CustomInputProps> = ({
  defaultValue = '',
  placeholder = 'Enter text...',
  ...rest
}) => {
  const [value, setValue] = useState<string>(defaultValue);

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

  return (
    <input
      type="text"
      value={value}
      onChange={handleChange}
      placeholder={placeholder}
      {...rest}
    />
  );
};

export default CustomInput;

Usage:


import React from 'react';
import CustomInput from './CustomInput';

function App() {
  return (
    <div>
      <h2>Uncontrolled CustomInput</h2>
      <CustomInput defaultValue="Hello, World!" />
    </div>
  );
}

export default App;

Explanation:

  • State Management: The CustomInput maintains its own value state initialized with defaultValue.
  • Interaction: Users can type into the input, and the component updates its internal state accordingly.
  • Limitations: The parent component cannot directly access or modify the input’s value.

2. Controlled CustomInput

Now let’s allow other developers to control our component state, so that they can integrate it into their own projects. To make the CustomInput controlled, we’ll allow the parent component to manage its value via value and onChange props. If the props are not provided, default to uncontrolled state. Otherwise, always give priority to the user’s props.


import React, { useState, InputHTMLAttributes } from 'react';

interface CustomInputProps extends InputHTMLAttributes<HTMLInputElement> {
  value?: string;
  onChange?: (value: string) => void;
  defaultValue?: string;
  placeholder?: string;
}

const CustomInput: React.FC<CustomInputProps> = ({
  value: valueProp,
  onChange: onChangeProp,
  defaultValue = '',
  placeholder = 'Enter text...',
  ...rest
}) => {
  const isControlled = valueProp !== undefined;
  const [internalValue, setInternalValue] = useState<string>(defaultValue);
  const value = isControlled ? valueProp : internalValue;

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (!isControlled) {
      setInternalValue(e.target.value);
    }
    if (onChangeProp) {
      onChangeProp(e.target.value);
    }
  };

  return (
    <input
      type="text"
      value={value}
      onChange={handleChange}
      placeholder={placeholder}
      {...rest}
    />
  );
};

export default CustomInput;

Usage:


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

function App() {
  const [inputValue, setInputValue] = useState<string>('Controlled Input');

  const handleChange = (value: string) => {
    setInputValue(value);
  };

  const resetInput = () => {
    setInputValue('');
  };

  return (
    <div>
      <h2>Controlled CustomInput</h2>
      <CustomInput value={inputValue} onChange={handleChange} />
      <button onClick={resetInput}>Reset</button>
    </div>
  );
}

export default App;

Explanation:

  • State Management: The parent component (App) manages the inputValue state.
  • Interaction: The CustomInput receives value and onChange props to reflect and update the state.
  • Advantages: The parent can control the input’s value, reset it, or perform other state-based operations.

3. Semi-Controlled CustomInput with Refs

In some cases, you might want the component to manage its own state but still allow the parent to perform certain actions, such as resetting the input. We’ll achieve this using refs.


import React, {
  useState,
  forwardRef,
  useImperativeHandle,
  InputHTMLAttributes,
} from 'react';

interface CustomInputProps extends InputHTMLAttributes<HTMLInputElement> {
  value?: string;
  onChange?: (value: string) => void;
  defaultValue?: string;
  placeholder?: string;
}

export interface CustomInputRef {
  reset: () => void;
  getValue: () => string;
}

const CustomInput = forwardRef<CustomInputRef, CustomInputProps>(
  (
    {
      value: valueProp,
      onChange: onChangeProp,
      defaultValue = '',
      placeholder = 'Enter text...',
      ...rest
    },
    ref
  ) => {
    const isControlled = valueProp !== undefined;
    const [internalValue, setInternalValue] = useState<string>(defaultValue);
    const value = isControlled ? valueProp : internalValue;

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      if (!isControlled) {
        setInternalValue(e.target.value);
      }
      if (onChangeProp) {
        onChangeProp(e.target.value);
      }
    };

    useImperativeHandle(ref, () => ({
      reset: () => setInternalValue(defaultValue),
      getValue: () => value,
    }));

    return (
      <input
        type="text"
        value={value}
        onChange={handleChange}
        placeholder={placeholder}
        {...rest}
      />
    );
  }
);

export default CustomInput;

Usage:


import React, { useRef } from 'react';
import CustomInput, { CustomInputRef } from './CustomInput';

const App = () => {
  const inputRef = useRef<CustomInputRef>(null);

  const resetInput = () => {
    inputRef.current?.reset();
  };

  const getInputValue = () => {
    if (inputRef.current) {
      alert(`Current Input Value: ${inputRef.current.getValue()}`);
    }
  };

  return (
    <div>
      <h2>Semi-Controlled CustomInput</h2>
      <CustomInput ref={inputRef} defaultValue="Semi-Controlled Input" />
      <button onClick={resetInput}>Reset Input</button>
      <button onClick={getInputValue}>Get Input Value</button>
    </div>
  );
};

export default App;

Explanation:

  • State Management: The CustomInput can operate as either controlled or uncontrolled based on whether the value prop is provided.
  • Refs and Imperative Methods:
    • forwardRef: Allows the parent to obtain a ref to the CustomInput.
    • useImperativeHandle: Exposes specific methods (reset and getValue) to the parent.
  • Interaction: The parent can reset the input to its default value or retrieve its current value without fully controlling its state.

Best Practices and Considerations

  • Don’t Mix Controlled and Uncontrolled Props: Avoid using both value and defaultValue props simultaneously. This can lead to unexpected behavior.
  • Consistent Behavior: A component should not switch between controlled and uncontrolled modes during its lifecycle. Decide the mode during initial render.
  • Prop Naming: When exposing methods via refs, use clear and descriptive names to avoid confusion.
  • State Synchronization: Be cautious when updating internal state based on props or refs to prevent synchronization issues.

Conclusion

By allowing your components to operate in controlled, uncontrolled, or semi-controlled modes, you provide flexibility to other developers using your components. They can choose the mode that best fits their use case, making your components more versatile and easier to integrate.

Remember to document the expected behavior of your component in each mode. Clear documentation helps others understand how to use your component effectively.


Back to blog