© 2024 Jeremiah Yee
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.
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.
Before diving into the code, let’s clarify what we mean by controlled, uncontrolled, and semi-controlled.
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.
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.
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.
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:
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.
CustomInput
ComponentLet’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.
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:
CustomInput
maintains its own value
state initialized with defaultValue
.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:
App
) manages the inputValue
state.CustomInput
receives value
and onChange
props to reflect and update the state.CustomInput
with RefsIn 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:
CustomInput
can operate as either controlled or uncontrolled based on whether the value
prop is provided.forwardRef
: Allows the parent to obtain a ref to the CustomInput
.useImperativeHandle
: Exposes specific methods (reset
and getValue
) to the parent.value
and defaultValue
props simultaneously. This can lead to unexpected behavior.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