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)
}
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
}
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>
After compound component
<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:
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.
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.
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.
Top comments (1)
So, tell us the difference?