SECRET OF CSS

5 Recipes for Setting Default Props in React and TypeScript | by Guillaume Renard | Sep, 2022


Let’s learn to cook our props the right way!

image by author

Default values in function parameters and object properties are very powerful. They allow consumers to keep their code simple while leaving the door open for customization.

In this article, I’ll be sharing five of my best recipes to set default properties — each with its own use cases.

I’ll start with the easiest one, which you are probably already using. This pattern will cover 80% of the use cases.

Let’s say you wanted to implement a function that looked for an object in a list, allowing the caller to specify an optional index to start searching at. Since the index is optional, you can assign a default value to it directly in the head of the function. Here’s the code:

Note that you don’t have to specify a type for fromIndex since TypeScript implicitly assumes it’s a number thanks to the default value (0).

The same technique also works for functional components (they are functions after all). So, for the sake of completeness, let’s look at a quick example too.

The component in question has an optional variant property, which is used to determine what style to apply to it. You can set a default value directly in the function head too. Here’s what that looks like:

Notice that Props is an object, and we are destructuring it into three variables:

  1. value, not optional
  2. variant, optional and initialized to a default value ("filled")
  3. others, the rest of the properties in the object

If your optional property is a boolean and its default value is false, the calling code can be simplified.

For example, if you have a Button component with a disabled property set to false by default, such as:

export function Button({ disabled = false } : Props) { /* ... */ }

then you can add a valueless disabled attribute to the Button:

<Button disabled />

If the attribute is present, the property will be automatically set to true, and if it’s omitted, it will be false (thanks to the default value).

From this point, I will stop making a distinction between functions and components because, as we’ve seen, they are the same (they are both functions).

Now let’s say that you wanted to accept a more complex argument in parameter: an object. That object itself is optional, and so are its properties. So, you want to make sure that the caller is able to call you with either:

  • an object, with all properties included
  • an object, with some properties included — use default values for the remaining ones
  • no object — use default values all the way

You usually do that when you want to offer options or you accept a config in param. Developers can then either use a vanilla version of your code or tweak it for more granularity.

Now, let’s implement a custom hook for a change (it’s still a function). This hook provides a counter that you can customize with options, such as the number of steps to increment by, or min / max boundaries.

The final code may look like this:

We created a CounterOptions type line 3, where all fields are marked as optional, and where bounds is an object (because we like challenges).

Next, line 11 we declared the useCounter function. It takes two arguments: an optional initial value (set to 0 by default — line 14) and an optional object with the CounterOptions (set to an empty object {} — line 19).

The magic happens at lines 13–19. There, we destructured the options, and we assigned them a default value. And since bounds is an optional object, we also made sure to assign it an empty object {} by default, so that people can use our options without setting bounds. Each property in the bounds is also assigned a default value (lines 16–17).

You can set default values for properties in a nested object, no matter the level of nesting.

The implementation of the hook itself remains nice and simple: the code doesn’t need to check if the parameters are initialized (they are, always), and it can access nested props directly (we’ve destructured them into separate variables — no need to type props.bounds.max to access max).

Now you can use the useCounter hook in your code with maximum flexibility! These are all valid use cases:

const counter = useCounter();
const counter = useCounter(1);
const counter = useCounter(0, { steps: 2 });
const counter = useCounter(10, { bounds: { min: 0 } });
const counter = useCounter(0, { bounds: { max: 10 } });
const counter = useCounter(
0,
{ steps: 2, bounds: { min: 0, max: 10 } }
);

You can see it in action in this sandbox.

If you would like to export your defaults, you can also create constants for them. For example, look at this code:

To be honest, I’m not fond of this way of setting defaults. We lose some of the declarative syntax, and we introduce redundancy. So, if you’re going to use it, make sure you have a good reason for it.

Finally, you might also be wondering why we just don’t declare useCounter with:

export function useCounter(initial = 1, options = defaultOptions)

Well, if we do that, we let the responsibility to the developers using their own options to make sure they provide every single property in the object… It’s a take it or leave it situation. We can no longer rely on all options being initialized, and we also lose the benefit of object destructuring.

Sometimes, your component acts as a wrapper around another component. When that’s the case, you might want to return a component with default props and let the consumer add their own to it.

To demonstrate this, let’s create a wrapper for a YouTube video. If you go to YouTube and click the “Embed” button, you’ll get a code snippet with an iframe and the URL of the video in it.

In this example, we are going to extract the ID of the video to a property and write the rest of the markup ourselves. And since the embed video is an iframe, we’ll let the consumer override its properties as well. Here’s what that code looks like in action:

  • Line 4, we extended the ‘iframe’ component in order to inherit its props. Then we added a vid property to specify the identifier of the video, as well as another optional property that receives the starttime.
  • Line 12, we extracted our vid and start properties, as well as the remaining ones (others). We also set a default value of 0 for start, just so that we could practice setting simple props one more time. We also added a ref parameter, but we’ll get back to it later.
  • Line 14, we returned an iframe with the computed src, as well as some default attributes that YouTube gave us (width, height, title, etc.).
  • But the interesting bit is at line 23: {…others}. Thanks to this, the caller can supply any supported iframe attributes to our component, and have them override our defaults (including width, height, title, etc.). And since {…others} comes after all other props on the iframe, it will always override our values. If we wanted to prevent our values from being overridden, we could move them after {…others} (eg: <iframe {…others} src={`https…`} /> .

This pattern enables all the following use cases:

<YouTubeVideo vid="eX2qFMC8cFo" />
<YouTubeVideo vid="eX2qFMC8cFo" start={10} />
<YouTubeVideo vid="eX2qFMC8cFo" width="800" height="600" />
<YouTubeVideo vid="eX2qFMC8cFo" allowFullScreen={false} />
<YouTubeVideo vid="eX2qFMC8cFo" style={{border: '1px solid red'}} />

You can play with it in this sandbox.

With this recipe, you can extend any HTML element (an iframe, a button, a div, etc.), and even more advanced components such as MUI Buttons or Text Fields.

If you look at the code, you might be wondering what is the ref parameter for (line 12)? Right. The easiest way to understand is to remove it, along with React.forwardRef (line 9).

Then try to get a reference to the iframe element in your app, as you would do with a ‘normal’ iframe:

const ref = useRef<HTMLIframeElement>();
// ...
<YouTubeVideo vid="eX2qFMC8cFo" ref={ref} />

You’ll get a warning in the console:

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?Check the render method of `App`.
at YouTubeVideo (https://bt298u.csb.app/src/YouTubeVideo.tsx:25:18)
at div
at App

The code we added to our component fixes the issue. So, if you expect your consumers to use a ref, do not forget to forward it to the wrapped element as it’s not automatic. You can learn more about this in the React docs.

Similar to the previous example, sometimes you just want to create a component with default props and let the caller override them as needed.

Here is a pattern that I like to use in my unit tests. It uses the Partial type of TypeScript to make all properties optional (even the ones that are not marked optional).

So, let’s write some tests for the YouTubeVideo component we implemented earlier:

Lines 4–6, we define a renderVideo function, which is a wrapper around the popular React Testing Library. Inside it, we simply render a YouTubeVideo component with default props (vid=eX2qFMC8cFo) and let the caller override them with partial YouTubeVideoProps.

Then we are able to call renderVideo in each test case. We can either call it without props, to verify that the component works with default values (lines 8–10), or we can override each prop individually and test that it behaves as it should (vid lines 12–18 and start lines 20–26).

You can of course use this recipe outside of unit tests. Just remember that if you want to offer a way to provide an object with ‘holes’, Partial is your friend.

Did you know that the default value of a prop could be computed dynamically, at call time? Yes, you can use a function to compute it. You can also instantiate a new object or use a template string. And you even have access to the earlier params to compute it!

In this example, we are calling the Date.now() function to initialize a timestamp, if not provided by the caller:

function addEntry(text: string, timestamp = Date.now())

Neat!

And since we have access to the earlier parameters when setting defaults, we can also do this:

function addEvent(
title: string,
start = new Date(),
end = addMinutes(start, 30)
)

In this example, we initialized the optional start date to the current date (start = new Date()), and then we initialized the optional end date with a function that adds 30 minutes to the start date (end = addMinutes(start, 30)).

And here is another example that uses a template string to initialize the full name of a user:

function createUser(
name: string,
surname: string,
fullname = `${name} ${surname}`
)

I also want to mention that you can access earlier properties in a destructured object too. This means that the following also works (notice the curly brackets — we take an object in param this time):

function createUser({
name: string,
surname: string,
fullname = `${name} ${surname}`
})

That’s all for today!

If you liked my recipes, follow me for more like these. 🧑‍🍳



News Credit

%d bloggers like this: