DEV Community

Mike Wu
Mike Wu

Posted on

Compound Components, and Implicit Prop Binding in TS

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:

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

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

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

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

<Route path="/restaurant/:restaurant_id/menu/editor">
  <MenuEditor />
</Route>
Enter fullscreen mode Exit fullscreen mode

After compound component

<Route path="/restaurant/:restaurant_id/menu/editor">
  <Restaurant>
    <MenuEditor />
  </Restaurant>
</Route>
Enter fullscreen mode Exit fullscreen mode

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

export default function MenuEditor({restaurantId}) {
  const {fetch} = useMenu()
  fetch(restaurantId).then(...update state).catch(...handle error)
}
Enter fullscreen mode Exit fullscreen mode

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.

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

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.

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

   // 🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔🍔
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
jorisw profile image
Joris W

Hint: You need to know the difference between ReactNode and ReactElement.

So, tell us the difference?