DEV Community

Gavin Sykes
Gavin Sykes

Posted on • Edited on

Self-referencing types and generics: what are they and how can I use them in React?

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>) {}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
type ControlledTextInputProps = ComponentWithStateValueAndSetterProps<string>;

export function ControlledTextInput({ setValue, value }: ControlledTextInputProps) {
  return <input type="text" value={value} onChange={e => setValue(e.currentTarget.value)} />
}
Enter fullscreen mode Exit fullscreen mode
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} />
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
  • 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;
}
Enter fullscreen mode Exit fullscreen mode
  • 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)