Compound Components, and Implicit Prop Binding in TS
Tags: react, typescript
Published: 2020-03-30 14:57:38

*You should have working knowledge of React, and Typescript to get the most out of this post.*

---

So recently learning about compound components, and the ability to pass props ~~implicitly~~ magically to child components, I really wanted to try it out. Here's an example of an **unnecessary** use-case for both.

#### TL;DR: Compound components are no fun in TS, and if you're not sharing state between child components, or rendering elements, think about using a custom hook instead.

Alright so I had a component that was getting route params from React Router, and I wanted to do something like this:

```js
export default function MenuEditor() {
  const {restaurant_id} = useParams()

  const history = useHistory()
  if (restaurant_id === undefined) {
     history.push('/restaurants')
     return null // <--- Nope!
  }

  const restaurantId = parseInt(restaurant_id)
  const {fetch} = useMenu()
  fetch(restaurantId)
}
```

But React won't let you do that right? Conditional hooks and all that. `useEffect` saves the day:

```js
export default function MenuEditor() {
  const {restaurant_id} = useParams()
  const history = useHistory()
  const {fetch} = useMenu()

  useEffect(() => {
    if (restaurant_id === undefined) {
      history.push('/restaurants')
      return
    }

    const restaurantId = parseInt(restaurant_id)
    fetch(restaurantId).then(...update state).catch(...handle error)
  }, [history, restaurant_id])

  //... do more stuff
}
```

Cool and that worked but it didn't feel right. *Why should the menu editor have to know where to redirect, right?* But I couldn't think of anything better at the time, so I moved on.

Fast-forward to me learning about **compound components**, and wanting to see if it's the solution to all my problems, and here we are.

### Before compound component

```jsx
<Route path="/restaurant/:restaurant_id/menu/editor">
  <MenuEditor />
</Route>
```

### After compound component

```jsx
<Route path="/restaurant/:restaurant_id/menu/editor">
  <Restaurant>
    <MenuEditor />
  </Restaurant>
</Route>
```

Disappointed yet? Don't be. You'll have plenty of chance later, check out the menu editor now:

```js
export default function MenuEditor({restaurantId}) {
  const {fetch} = useMenu()
  fetch(restaurantId).then(...update state).catch(...handle error)
}
```

See it? Yep, it received `restaurantId` as a prop even though we never passed it in. And guess where it lives? In the `Restaurant` (parent) component! It's called **implicit prop binding**, and it's pretty neat.

So figuring how to do this in Typescript took longer than I care to admit. *Hint: You need to know the difference between `ReactNode` and `ReactElement`.* 

If you've got a better way to do this without lying to the compiler, I'd love to know.

```tsx
interface RestaurantChild {
  restaurantId: number
}

export default function Restaurant(props: {children: React.ReactNode}) {
  const {restaurant_id} = useParams()
  const history = useHistory()

  // Redirect logic is now in a much better place
  if (restaurant_id === undefined) {
    history.push('/restaurants')
    return null // Don't render children
  }

  const restaurantId = parseInt(restaurant_id)

  const childrenWithProps = React.Children.map(props.children, (child) => {
    // This is absolutely required TS
    if (!React.isValidElement<RestaurantChild>(child)) {
      return child
    }

    return React.cloneElement(child, {
      restaurantId,
    })
  })

  // ...and this is also required TS
  return <>{childrenWithProps}</>
}
```

### But we're not done

Unfortunately we'll have to turn `restaurantId` into an optional prop to keep TS happy. *Typescript is starting to feel like a one-sided relationship.*

```js
export default function MenuEditor(props: {restaurantId?: number}) {
   if(!resturantId) {
     throw new Error( `Missing 'restaurantId'; MenuEditor must be rendered inside a Restaurant.`)
   }

   // 🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔
}
```

And now we're done. Let's recap:

- Better separation of concern? ✅
- Reduce repetition? ❌
- Easier to debug? ❌
- Required for shared state? ❌

So that's a 3:1 ❌ ratio which makes me think I might just re-write this with an explicit `useRestaurant()` hook instead. Be right back.