:heart: React

On function signatures

May 28, 2021

Where to destructure props, and assign default values to them.


This is an interesting topic affecting developer experience: code duplication, code readability and cognitive load.

There are ways to define component props and associate default values to them.

A common approach is to destructure props in the function signature. Another approach is to destructure them in the function body.

// Destructuring in function signature
function Video({prop1, prop2}: TVideo) {...}

// Destructuring in function body
function Video(props: TVideo) {
  const {prop1, prop2} = props
}

Associating default props comes with at least three different approaches.

// Associating default props in function signature
function Video({prop1, prop2}: TVideo = video) {...}

// Associating default props in function signature at destructuring
function Video({prop1 = 'prop1', prop2 = 'prop2'}: TVideo) {...}

// Associating default props in function body
function Video(props: TVideo) {
  const {prop1, prop2} = merge(props, video)
}

Which approach is better? Which approach is complete? What makes an approach better than another?

To answer the questions let’s focus first on where to destructure. Then on how to associate default props. Then combine the findings into a verdict.

Destructuring

Destructuring leads to code duplication. In the case of a large number of props might lead to code readability problems. And depending on the scenario may take a toll on cognitive load.

Code duplication (Is inevitable)

Assuming type definitions are in place, destructuring leads inevitably to code duplication:

interface TVideo {
   prop1: string,
   prop2: string
}

// Duplication in function signature
function Video({prop1, prop2}: TVideo)

// Duplication in function body
function Video(props: TVideo) {
   // The same amount of duplication as above
   const {prop1, prop2} = props
   // When not all props are needed ...
   // ... this approach contains less duplication
   const {prop1} = props
}

The advantage goes to the function body approach. It can lead to less duplication.

Usage info on hover (Is incomplete)

Editors try to offer information about functions on hovering or clicking their name.

This comes handy when trying to use a function. It gives hints on usage and return value.

Editors vary in capability to display information on hover. In my experience VSCode performs better than Atom. Or Atom just needs a better plugin. Other editors might perform better than VSCode.

Differences in editor capabilities reduce the importance of this criteria.

For curiosity, and pursuing a better development experience, after playing with various scenarios in VSCode I found:

  1. Hover simply returns the function signature, as is, and the function return type.

Hover


  1. When the return type is not defined it is inferred.

Hover


  1. The defined return type hint is less complete than the inferred one.

Hover


  1. ctrl+hover returns the first 10 lines of the function, as is.

Ctrl+Hover


Based on the above there is no difference between the two destructuring approaches.

A hover when destructuring is in the function signature gives the same information as a ctrl+hover when destructuring is in the function body.

Hover


Ctrl+Hover


In both cases the hint information is incomplete. There is no type information on prop1, prop2.

Default props

Before assigning default props to function props they should be set up to catch missing function props.

On assigning them, the prop structure defines which method to use. In case of a small number of flat props a pretty good usability can be reached.

Defining default props

Default props prevent errors when destructuring undefined props and trying to use them.

Destructuring is inevitable in nested props, and nested props will be present in your code sooner or later.

One can circumvent destructuring by using optional chaining.

// Optional chaining
// - On deeply nested props leads to trainwreck:
// prop1?.prop1a?.prop1aX?.prop1aX...
const text = prop1?.prop1a
const numbers = prop2?.prop2a?.map(item => item).join(',')

In the long term, when nesting goes deeper, destructuring scales better.

// Destructuring
// - On deeply nested props works fine
const { prop1, prop2 } = props
const { prop1a } = prop1
const { prop2a } = prop2
const numbers = prop2a?.map(item => item).join(',')

Missing props

Optional chaining handles missing props. Destructuring fails with Unhandled Runtime Error.

When prop1 is null:

// Returns value `undefined`
const text = prop1?.prop1a
const { prop1, prop2 } = props
// Breaks with an error
const { prop1a } = prop1

To make destructuring error proof, default values must cover the full depth.

export interface TNested {
  prop1?: {
    prop1a: string,
  };
}

export const nested: TNested = {
  // This doesn't go into full depth.
  // It will give an error on destructuring.
  prop1: null,
}

export const nestedFullDepth: TNested = {
  // This is ok.
  prop1: {
    prop1a: null,
  },
}

Default prop assignment

Once error-proof and nested default props are set up — they should be assigned to function props.

In JavaScript object assignment works only with flat objects. Nested objects need a special function to perform the same task. Lodash offers such a function: defaultsDeep, to recursively assign arbitrarily nested default props to function props.

Associating default props to props in function signature is not straightforward, might be impossible, or might require expert knowledge I don’t have at the moment.

// This gives the error:
// Parameter '{ prop1, prop2 }' cannot reference identifier 'prop1' declared after it.ts(2373)
function Video({prop1, prop2}: TVideo = defaultsDeep({prop1, prop2}, nestedFullDepth)) {...}
// This gives the error:
// No value exists in scope for the shorthand property 'prop1'. Either declare one or provide an initializer.
function Video({...defaultsDeep({ prop1, prop2 }, nestedFullDepth)}: TNested) {...}

Associating default props in function body just works:

// This works.
function Video(props: TVideo) {
  const { prop1, prop2 } = defaultsDeep({ ...props }, video)
}

The advantage goes to the function body approach. It works as is.

Usage info on hover

It’s time to revisit how editors display hover hints on functions now with default prop assignments.

Associating default props in function signature

function Video({prop1, prop2}: TVideo = video) {...}

Hover

ctrl+hover displays the same amount of information.

Associating default props in function signature at destructuring

function Video({prop1 = 'prop1', prop2 = 'prop2'}: TVideo) {...}

Hover

ctrl+hover displays better information:

Ctrl+Hover

Associating default props in function body

function Video(props: TVideo) {
  const { prop1, prop2 } = merge(props, video)
}

Hover

ctrl+hover displays better information:

Ctrl+Hover

Summing up

Where to destructure props, and assign default values to them depends on the shape of the props.

When props are flat and small in number, destructuring in function signature + assigning default values at destructuring, wins. ctrl+hover over the function name displays good enough usage information.

Nested props and the requirement to use a deep merging function implies destructuring in the function body.

The developer experience with ctrl+hover over the function name is less pleasant: the default values, helping to infer the prop type, are not shown.

// Props are flat and small in number.
// This approach is recommended.
//
// This approach gives the best developer experience:
// On `ctrl+hover` the type of the props can be inferred from their default value.
function Video({prop1 = 'prop1', prop2 = 'prop2'}: TVideo) {...}
// Props are flat but large in number.
// This approach is not recommended.
function Video({
  prop1 = 'prop1',
  prop2 = 'prop2',
  prop3 = 'prop3',
  ...
  ...
  prop10 = 'prop10'
  }: TVideo) {...}
// Props are flat but large in number.
// This approach is recommended for better code readability.
//
// On `ctrl+hover` this approach doesn't give hints about the type of the props.
function Video(props: TVideo) {
  const propsMerged = defaultsDeep({ ...props }, defaultProps)
  const { prop1, prop2 } = propsMerged
}
// Props are nested.
// This approach is recommended (perhaps the only viable option).
function Video(props: TVideo) {
  const propsMerged = defaultsDeep({ ...props }, defaultProps)
  const { prop1, prop2 } = propsMerged
}

Metamn

To React with best practices. Written by @metamn.