DEV Community

Cover image for Default Props in React/TS - Part Deux
Adam Nathaniel Davis
Adam Nathaniel Davis

Posted on • Updated on

Default Props in React/TS - Part Deux

A few days ago, I posted a long article about my struggle to find a solution for setting default prop values in React/TS components. Based on feedback in the comments, I toyed around with several other approaches, but I ultimately settled (for now) on a revised approach to the "solution" in that article. I'll outline that here.

A Quick Recap

I'm a long-time React/JS dev (and even longer with other JS frameworks, going back to the advent of jQuery). For the first time, I'm working on a team where we're spinning up a "green fields" React/TS project. It's not like TS is completely foreign to me. After all, I've done several years of C# dev. But converting my "standard" JS knowledge into TS for the first time still requires a bit of acclimating.

Specifically, I want to be able to create React/TS components that fit the following parameters (parameters that were extremely easy to implement in React/TS):

  1. I'm creating functional components (as opposed to class-based components).

  2. Those functional components must be able to accept a single object containing all the properties (i.e., props) that were passed into the component. This is standard React functionality.

  3. I must be able to annotate the types associated with each prop value. (This is also standard React functionality, but it should obviously fit quite nicely into TypeScript.)

  4. I must be able to designate some props as required - while others may be optional. (Again, pretty standard stuff in both React/JS & React/TS.)

  5. For any prop that is optional, I need the ability to designate a default value for that prop, if none is supplied when the component is invoked.

  6. Inside the body of the functional component, I wanna be able to reference any of the props values in a single object. In React/JS, these are often referenced as props.foo or props.bar. But I wouldn't mind if the name of that object is something else, such as args or params or whatever.

  7. I don't wanna use any solutions that are in imminent danger of being deprecated. (This is why I'm not using the native defaultProps feature that currently ships with React. There's a lot of chatter about removing this feature for functional components.)

  8. BONUS: I'd really prefer to not have to manually define props.children - only because, in React/JS, this is never necessary. In React/JS, props.children is just sorta "there" - for free.

This may feel like a big pile of requirements. But most of them are "requirements" that were pretty standard or easy to achieve before I switched from React/JS to React/TS.

My Previous "Solution"

A few days ago, this was my working solution:

//all.props.requires.ts
export type AllPropsRequired<Object> = {
   [Property in keyof Object]-?: Object[Property];
};

// my.ts.component.tsx
interface Props extends PropsWithChildren<any>{
   requiredString: string,
   requiredNumber: number,
   optionalBoolean?: boolean,
   optionalString?: string,
   optionalNumber?: number,
}

export default function MyTSComponent(props: Props) {
   const args: AllPropsRequired<Props> = {
      ...props,
      optionalBoolean: props.optionalBoolean !== undefined ? props.optionalBoolean : true,
      optionalString: props.optionalString !== undefined ? props.optionalString : 'yo',
      optionalNumber: props.optionalNumber !== undefined ? props.optionalNumber : 42,
   };
   console.log(args);

   const getLetterArrayFromOptionalString = (): Array<string> => {
      return args.optionalString.split('');
   };

   return (
      <>
         Here is MyComponent:<br/>
         {props.children}
      </>
   );
}
Enter fullscreen mode Exit fullscreen mode

First, a big shout-out to @chico1992 for pointing out that my custom partial AllPropsRequired<> is only recreating what TS already provides with Required<>. So I've washed that out of my solution.

Second, that same commenter also gave me some useful working code to look at other ways to encapsulate the default values right into the function signature itself. However, even with those (awesome) suggestions, I was still stuck with the idea of having to manually chunk the required/optional values into a new object, which I didn't really like.

So I went back to the drawing board and came up with, what seems to me for now, to be a better solution.

Solution - Part Deux

In my first solution above, there's some verbose, clunky verbiage designed to set the default value on any optional prop that wasn't provided. It's the section that looks like this:

   const args: AllPropsRequired<Props> = {
      ...props,
      optionalBoolean: props.optionalBoolean !== undefined ? props.optionalBoolean : true,
      optionalString: props.optionalString !== undefined ? props.optionalString : 'yo',
      optionalNumber: props.optionalNumber !== undefined ? props.optionalNumber : 42,
   };
Enter fullscreen mode Exit fullscreen mode

That's not the worst chunk of code that I've ever spit out, but it's definitely not very "clean". So I got to thinking:

What if I could create a setDefaults() function that would auto-magically do a lot of that stuff for me?


That led me to create the following universal helper function:

// set.defaults.ts
export default function setDefaults<Props, Defaults>(props: Props, defaults: Defaults): Required<Props> {
   let newProps: Required<Props> = {...props} as Required<Props>;
   const defaultKeys = Object.keys(defaults) as (string)[];
   defaultKeys.forEach(key => {
      const propKey = key as keyof Props;
      const defaultKey = key as keyof Defaults;
      Object.defineProperty(newProps, key, {
         value: props[propKey] !== undefined ? props[propKey] : defaults[defaultKey],
      });
   });
   return newProps;
}
Enter fullscreen mode Exit fullscreen mode

Some of you TS pros may see some other opportunities for optimization there. So I'm not claiming that setDefaults() is in its final form. But this one function does some nice things for me.

It accepts the existing props and a second, generic object that gives the definition for any prop keys that should have a default value. It then uses generics to return a props object that adheres to whatever type was originally defined.

And here's what the revised code looks where setDefaults() is now used:

interface Props extends PropsWithChildren<any>{
   requiredString: string,
   requiredNumber: number,
   optionalBoolean?: boolean,
   optionalString?: string,
   optionalNumber?: number,
}

export const MyTSComponent: FC<Props> = (props: Props) => {
   const args = setDefaults(props, {
      optionalBoolean: true,
      optionalString: 'yo',
      optionalNumber: 42,
   });
   console.log(args);

   const getLetterArrayFromOptionalString = (): Array<string> => {
      return args.optionalString.split('');
   };

   return (
      <>
         Here is MyComponent:<br/>
         {props.children}
      </>
   );
}
Enter fullscreen mode Exit fullscreen mode

Obviously, if you don't have any optional props, or if you don't want any default values to be set on those props, then you never need to call setDefaults() inside the function at all.

If you do have optional props that require default values, it's now done with code that's as just as simple/efficient as the native defaultProps feature.

In fact, I personally like this approach better, because when you use defaultProps, those default values end up getting set somewhere else in the file in a way that is not always easy to "grok" when you're reading through the code. With this approach, I'm not setting the default values in the function signature, but they reside right underneath it. So they should be easy to spot when simply reading the code.

I've also switched to using React.FC as the type for the functional component. When using this type, and setting the interface to extend PropsWithChildren<any>, I don't have to define props.children. It's there by default, on the props object.

This approach also solves the problem of the optional properties having a type like string | undefined or number | undefined. That additional | undefined causes headaches with the TS compiler because it forces you to write code that's tolerant of undefined values - even after you've set a default value on the prop and you know it will never be undefined.

Conclusion

I still stand by the theme of my original rant in the prior article. This shouldn't be this hard. This is extremely easy in React/JS. But getting it to work in React/TS required a ridiculous amount of research. Perhaps even more frustrating, it led to a number of confused shrugs when I tried to query longtime-TS devs about how to solve this.

One of the more annoying aspects of this journey was listening to the responses where TS devs told me stuff like, "You shouldn't worry about having all of your props in a single object." I'm sorry, but having all of the props in a single object is a very standard pattern that's outlined repeatedly in React's core docs. The idea that I should just discard this convention because I'm switching to functional React/TS components is, well... silly.

Knowing myself, I'll probably throw out this solution in another month (or less). But for the time being, this feels like the closest thing to an "answer".

Please feel free to point out anything that I've screwed up or overlooked!

Oldest comments (17)

Collapse
 
anuraghazra profile image
Anurag Hazra

What's wrong with Component.defaultProps = {}??

Or you are just digging into rabbit hole for educational purposes?

Collapse
 
bytebodger profile image
Adam Nathaniel Davis

From the article above, Requirement #7:

I don't wanna use any solutions that are in imminent danger of being deprecated. (This is why I'm not using the native defaultProps feature that currently ships with React. There's a lot of chatter about removing this feature for functional components.)

I also discussed the presumed deprecation plans in the previous article.

Collapse
 
anuraghazra profile image
Anurag Hazra

Ohh I see, okay this makes sense. :D

Collapse
 
ecyrbe profile image
ecyrbe

Hi Adam,

You may want to try this :

type Optionals<T extends object> = Required<Pick<T, Exclude<{
    [K in keyof T]: T extends Record<K, T[K]> ? never : K
}[keyof T], undefined>>>;

function setDefaults<Props extends object>(props: Props, defaults: Optionals<Props>): Required<Props> {
    return Object.assign({ ...props }, ...Object.keys(defaults).map(key => ({ [key]: props[key] ?? defaults[key] })));
}
Enter fullscreen mode Exit fullscreen mode

it's shorter, delegates type checking of optionals to the type Optionals<T>. I suggest you try the code to understand it. it needs typescript 3.8 (worth upgrading for ?. and ?? operators ).

Collapse
 
bytebodger profile image
Adam Nathaniel Davis

Thank you! Since I'm new to the TS stuff, your solution looks a bit gobbledy-gookish to me at first, so I'll definitely look at it carefully to make sure that I truly grok it before putting it in. But this looks very promising!

And we are already using TS 3.8.3 - so ? and ?? operators shouldn't be a problem.

Cheers!

Collapse
 
ecyrbe profile image
ecyrbe • Edited

Yes, the hardest part is this one :

{ [K in keyof T]: T extends Record<K, T[K]> ? never : K }
Enter fullscreen mode Exit fullscreen mode

Witch means, return me an object type that has the same properties as Props, but with property types equal to the property name if the underlying property is not required .
then :

{ [K in keyof T]: T extends Record<K, T[K]> ? never : K }[keyof T]
Enter fullscreen mode Exit fullscreen mode

that means return me a union of all the optional properties names of Props.

Then the idea is to have setDefaults(), not compile if you try to pass default parameters not declared in Props, or if you forgot to add defaults that where declared optional in Props.

This is where you will thank typescript for having your back everytime you forgot to handle a default parameter, because typescript will catch it.

Thread Thread
 
bytebodger profile image
Adam Nathaniel Davis

Excellent stuff. And thank you(!) for the extended explanation. I've been coding for 20+ years and I've done plenty of generics in languages like C#. But when you're in a "JavaScript mindset", it can still take a little bit to get your head back in that mindspace.

All though I'm generally a big fan of generics, when you haven't been working with them for awhile, code like that above can look like the first time that you dove into regular expressions.

Cheers!

Collapse
 
mlavoiedev profile image
mlavoiedev

Hi Adam,

It's my first time commenting here but I came across the same problem last week and I wanted to share my solution with you.

type MyComponentProps = {
  // Required
  requiredNumberProp: number;

  // Not required
  booleanProp?: boolean,
  stringProp?: string,
}

// We need the Partial notation so we don't have to declare required props
const DEFAULT_PROPS: Partial<MyComponentProps> = {
  booleanProp: true,
  stringProp: 'FOO',
};

const MyComponent = (props: MyComponentProps) => {
  // We're spreading DEFAULT_PROPS before actual props to overide them with the real ones
  const {
    requiredNumberProp,
    booleanProp,
    stringProp,
  } = { ...DEFAULT_PROPS, ...props }; 

  // We still need to validate stringProp before using the split() method
  //  But that's ok !
  //  Because if we use this component, we can totally do something like this :
  //  <MyComponent requiredNumberProp={42} stringProp={undefined} />
  //  So Typescript is right, we need to validate that the value is a string

  const splitText = stringProp ? stringProp.split('') : [];

  return `...`
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
bytebodger profile image
Adam Nathaniel Davis • Edited

I certainly appreciate your input. And if this solution works for you, then... great! But I have to say that, for me, there is one key aspect that basically falls short. It's in this:

  // We still need to validate stringProp before using the split() method
  //  But that's ok !
  //  Because if we use this component, we can totally do something like this :
  //  <MyComponent requiredNumberProp={42} stringProp={undefined} />
  //  So Typescript is right, we need to validate that the value is a string

  const splitText = stringProp ? stringProp.split('') : [];

Again, if that works for you, then... awesome! But I personally think this makes no sense. And it is part of the frustration I had in working through this problem.

If I have a prop - let's say it's X - and that prop is listed as an optional prop, of type string, then, with no further information, I understand why we need to validate whether X is of type string before we perform a .split() on it. This makes sense because the X variable could be a string OR it could be undefined.

But if that optional prop also has a default value set, then it makes no sense that I have to validate its data type before doing a .split(). Because the variable can never be undefined! If a value was provided in the prop, then TS is supposed to be ensuring us that this value was a string (and can thus be .split()). And if a value was not provided in the prop, then we know that the default value will be used - which is a string - and can be .split().

This is the heart of my (massive) frustration with this shortcoming in TS. If I've defined X as type string and I've provided a default string value, then I should never again have to validate that the prop value is, in fact, a string.

It will ALWAYS be a string. And it will NEVER be undefined.

Collapse
 
mlavoiedev profile image
mlavoiedev • Edited

Yeah I just understood that my DEFAULT_PROPS will not act as fallback values. My bad :( ! I did more research after thinking about that. What do you think of Typescript official solution to the problem ?

typescriptlang.org/docs/handbook/r...

function Greet({ name = "world" }: Props) {
    return <div>Hello {name.toUpperCase()}!</div>;
}
Thread Thread
 
bytebodger profile image
Adam Nathaniel Davis

I have several problems with their "official solution".

First, as stated in my previous rebuttals, this approach still wipes out the props namespace. In fact, look at their example. I think it kinda makes my case for me.

How many times have you had some function inside your component where there's a variable called something generic - like "name"?? And that's fine - but what if you also had a prop that was passed in called "name". Now you run into naming collisions. Because "name" is defined at the component level. So if you try to set something else as "name", you end up overwriting your props.

But if you have props.name, then you can always set some other variable to "name", secure in the knowledge that the props will always live under props.name.

The "official solution" also falls down if you want to use anything more than simple types. For example, what if you want to use a union type???

Imagine that "name" can be a string (like "Adam Davis") or an object (like, for example: {firstName: 'Adam', lastName: 'Davis'}. But there's no way to annotate that in the example above. You're stuck just using inference to determine the data type - and if you're gonna do that, might as well stick with JS.

Collapse
 
bytebodger profile image
Adam Nathaniel Davis

Also, I'll note that, for me at least, I don't "like" these kinds of solutions because (as I outlined in some parts of my article), I really appreciate the fact that, in "old skool" React/JS, the props are all inside the props object. I realize now, after talking to other "TS-types", that some people really don't care much about this. But for me, it's very important.

I really value the ability to simply read through code and know, on first read, that this value comes from props and this other value comes from some where else. And how do I know that just by reading a line-or-two of code? I know it because the prop values have props.xxxx right in their name.

Granted, in my "final solution", the props are shunted into another object - which I've called args. But the difference is semantic. The important point is that my props remain in an object namespace that clearly defines them as coming from the props.

Collapse
 
timdev profile image
Tim Lieberman

Unless I'm missing something, this can be simplified considerably if you're willing introduce a second variable inside your component to hold the props-with-defaults-applied. Have you considered something like:

import {PropsWithChildren} from 'react';

interface FooProps {
  id: number;
  email: string;
  nickname?: string;
}

const FooComponent = (propArgs: PropsWithChildren<FooProps>) => {
  // Our *actual* props
  const props = { ...{nickname: 'Anonymous Coward'}, ...propArgs };

  // Since we're referencing `props` and not `propArgs`, typescript
  // correctly infers that props.nickname is defined and is a string.
  console.log(props.nickname.split(' '));
}

FooComponent({id: 5, email: 'foo@example.com'}); 
// => [  'Anonymous', 'Coward' ]
FooComponent({id: 10, email: 'bar@example.com', nickname: 'Max Power'}); 
// => [ 'Max', 'Power' ]
Enter fullscreen mode Exit fullscreen mode

?

Collapse
 
bytebodger profile image
Adam Nathaniel Davis

Hmm... When I was going through this headache in June, I tried about 1,000 different things. But I don't think I specifically tried this. I do like leveraging the multiple-spread-operator approach to first set default values - and then overwrite them if they were provided in propArgs. I do this when I'm setting style attributes with CSS-in-JS.

I'm not seeing any downside to this approach at the moment. And it doesn't require any helper function/Hook.

Awesome!

Collapse
 
micmor profile image
Michael Morawietz

Hi,
TypeScript should make work easier, not harder. Although I find your article really interesting, your solution is really much too complex for me. Seems like React and Typescript don't really belong together. The best solution I have found so far is this one : /* eslint-disable react/require-default-props */. Short and painless.

Collapse
 
bytebodger profile image
Adam Nathaniel Davis

The best solution I've found so far isn't even the one outlined in this article. It's the one suggested by Tim Lieberman just above in the comments. However, the fact that it took me two articles to find that solution, and the fact that it's nowhere-near intuitive for someone who's just trying to switch from React/JS to React/TS, annoys me about how some things that are dead simple in React/JS can become incredibly convoluted in React/TS.

Collapse
 
micmor profile image
Michael Morawietz

But still, you showed me what's possible, thank you.