Hi everyone! So I have this React app that I am currently building, and I have a parent component which has a piece of state, and contains a child component which I want to be able to both receive and manage that state. It could be a controlled input component for a form, perhaps.
export function Form() {
const [username, setUsername] = useState('');
return (
<form>
<ControlledInput value={username} setValue={setUsername} />
</form>
)
}
Basically the simplest and most useless form you will ever see, I'm sure you agree!
Now, if we take a look at the props interface for our ControlledInput
we will see that nothing looks too out of the ordinary:
interface ControlledInputProps {
setValue: () => void;
value: string;
}
Simple enough, right?
Will this work? Yes.
Okay, so what is the problem?
Well, this presents 2 potential problems, mainly surrounding accuracy and maintainability.
Accuracy: if we focus just on the () => void
part for a second, we can see that it is not necessarily a state updater, but any function that does not return anything, so if I were to pass it even just setValue={() => {console.log("This will work fine, honest!")}}
, it would happily accept that and not tell me that there is an issue.
So, how do we fix this?
Well, React allows us to import 2 types: Dispatch
and SetStateAction
, both of these seem fairly self-explanatory, and those who have used useReducer
with TypeScript in the past will already be familiar with Dispatch
. SetStateAction
allows you to not have to worry about typing the action yourself, just pass it the type of the state you're updating as a generic so you'll finally end up with Dispatch<SetStateAction<string>>
:
interface ControlledInputProps {
setValue: Dispatch<SetStateAction<string>>;
value: string;
}
Immediately, even just to the human eye, is that not much more descriptive of what setValue
does? This also limits us to only passing in functions that are state updaters for strings. This does mean, though, that you still have to make sure you are passing in the right piece of state with its setter! If you pass value={username} setValue={setPassword}
then you will still have a bad time, and as of the time of writing this I am not too sure of a way around that. value={username} setValue={setAge}
, on the other hand? You'll get those warnings.
Okay, so what about maintainability?
Let's say we have another component other than an input that takes the same props:
interface ComponentProps {
setValue: Dispatch<SetStateAction<string>>;
value: string;
}
But, what if we update the app and now want this to take string | number
, or even string | number | null
?
interface ComponentProps {
setValue: Dispatch<SetStateAction<string | number | null>>;
value: string | number | null;
}
Not only is that starting to look a bit messy and long-winded, but it also means us having to update our types in 2 places at once. I do not care if those 2 places are right on top of each other, we are developers and therefore do not like updating the same thing in 2 places at once!
Enter self-referencing types
Lukily there is a little trick that will allow us, within a type declaration, to reference the very same type we are building, meaning we can define string | number | null | X | Y | Z
on one line and reference it elsewhere.
interface ComponentProps {
setValue: Dispatch<SetStateAction<ComponentProps['value']>>;
value: string | number | null;
}
This now tells the setValue
prop that it is a dispatcher for a SetStateAction for whatever type value
happens to be.
Now, the eagle-eyed among you will have noticed that ComponentProps['value']
is actually more characters than string | number | null
, so why are we making our description even longer? Sure, it explains why the type is how it is, whereas with it set to Dispatch<SetStateAction<string | number | null>>
we might have to do some digging to work that out, but is there an even neater way of doing this?
Enter generics
Generics allow us to save so much time when defining our types, because we needn't worry so much about typing things differently if an input happens to be a number instead of a string, or a boolean instead of a number. We simply pass T
and let it do its thing.
So in order to make our interface generic we do:
interface ComponentProps<T> {
setValue: Dispatch<SetStateAction<T>>;
value: T;
}
Then to make use of this new functionality we can go back to our ControlledInput and declare it as:
function ControlledInput({ setValue, value }: ComponentProps<string>) {}
Which means "hey, I want to have these props, and assign these types to them, based on the fact I'm working with a string".
This then opens the door to a reusable set of props that you can import into any component inside your app, and even extend from there.
export interface ComponentWithStateValueAndSetterProps<T> {
setValue: Dispatch<SetStateAction<T>>;
value: T;
}
type ControlledTextInputProps = ComponentWithStateValueAndSetterProps<string>;
export function ControlledTextInput({ setValue, value }: ControlledTextInputProps) {
return <input type="text" value={value} onChange={e => setValue(e.currentTarget.value)} />
}
Final step
This is all well and good, but now how do I add things like an id
or className
to my input, TypeScript won't let me!
Well, we can build upon more than 1 base type, depending on whether you prefer interface
or type
there are a couple of different ways of doing this.
interface ControlledTextInputProps extends HTMLProps<HTMLInputElement>, ComponentWithStateValueAndSetterProps<string>;
// OR
type ControlledTextInputProps = HTMLProps<HTMLInputElement> & ComponentWithStateValueAndSetterProps<string>;
export function ControlledTextInput({ setValue, value, ...inputElementProps }: ControlledTextInputProps) {
return <input type="text" value={value} onChange={e => setValue(e.currentTarget.value)} {...inputElementProps} />
}
HTMLProps
is another import from React that provides all the props that we can add directly to whichever element we pass as a generic, in this case, HTMLInputElement
. It makes them all optional so that we can pass in as many or as few as we want and name them exactly the same as if we were adding them to the element itself.
So now the component will take a whole host of additional props, such as className
or id
and apply them to the input
element.
One extra consideration
TypeScript has a whole bunch of native types available, many of which you will probably never actively use. With this in mind, you can restrict T
to a limited set of types, if you wanted to restrict it to be either a number or a string you'd do interface ComponentWithStateValueAndSetterProps<T extends string | number>
. Be mindful with this approach though, as you may find yourself accidentally disallowing certain types that you will make use of in state, such as arrays or objects.
Before:
interface ComponentProps {
setValue: () => void;
value: string;
}
- Shorter (hey, I had to find something as a pro!)
- Not type-safe
- Less descriptive
- Not customisable
- Reusable if and only if multiple components take the same props
- Easily updateable (because of the lack of type-safety)
After:
interface ComponentWithStateValueAndSetterProps<T> {
setValue: Dispatch<SetStateAction<T>>;
value: T;
}
- Longer (likewise, I had to find something as a con)
- Type-safe
- More descriptive
- Customisable
- Easily reusable
- Easily updateable (with no loss of type-safety, in fact, would it even need updating at all, due to the generic?)
Always remember that you're not writing code just for today, you're writing code for the person who will be using your application next year, the QA team who will be testing it in 5 years, and the developer who will be maintaining it in 10 years. Keep all of these people happy, because you might just be one of them!
Happy coding!
Top comments (0)