Component vs Prop drilling in React
June 8th, 2019
Part of the Of Hooks and Screens series
Recently, often the question is asked if Hooks and Context replaces Redux. It is more a question of do you need Redux, and perhaps how Hooks and Context can help you write cleaner, more functional, more composable code.
Hooks by themselves are an alternative way to write and reuse logic between components. So they’re not any more “alternative” to Redux than classes. They’re just a way to write and compose code.
— Dan Abramov (@dan_abramov) April 16, 2019
The question of whether you need Redux seems unrelated to Hooks to me.
I stumbled upon these topics in:
- https://dev.to/anssamghezala/you-don-t-have-to-use-redux-32a6
- https://dev.to/yakimych/seriously-do-react-hooks-replace-state-containers-3cpl
In these series, I will try to make a point why I think you can achieve most usecases without needing Redux, and perhaps how Hooks and Context may help you with that.
Let's first examine what is often recommended as alternative to Redux:
- A container component that centralizes the state, and then leverages "prop drilling" to pass props down to all child components. Creating coupling between the components, sometimes multiple levels deep.
- Multiple components that manage their own state and upgrade to Context/Provider API once state is more shared. Continueing "prop drilling". Also as clients sometimes have a hard time to make up their mind, it may lead to a lot of jumping around of where the state should live.
Improvement 1: Component Drilling instead of Prop Drilling
A prop drilling sample:
const SomeScreen = ({ someAction, someProp, someOtherProp, someOtherChildProp }) => (
<div>
<SomeComponent someProp={someProp} />
<SomeOtherComponent
someOtherProp={someOtherProp}
someOtherChildProp={someOtherChildProp}
action={someAction}
/>
</div>
)
const SomeOtherComponent = ({action, someOtherProp, someOtherChildProp}) => (
<div>
<SomeOtherChildComponent
prop={someOtherChildProp}
action={action}
/>
</div>
)
What makes this prop drilling is the fact that SomeOtherComponent
takes someOtherChildProp
and someAction
, which are actually props of the SomeOtherChildComponent
.
Now with component drilling:
const SomeScreen = ({ someAction, someProp, someOtherProp, someOtherChildProp }) => (
<div>
<SomeComponent someProp={someProp} />
<SomeOtherComponent someOtherProp={someOtherProp}>
<SomeOtherChildComponent
someProp={someOtherChildProp}
action={someAction}
/>
</SomeOtherComponent>
</div>
)
Here we stopped making SomeOtherComponent
responsible to pass the props for SomeOtherChildComponent
. Of course this moves the coupling from SomeOtherComponent
to the Screen instead. Better; it lives closer to the definition, there are less players involved in coupling.
Improvement 2: State in Hooks/HOCs, so that it can be easily upgraded/downgraded from shared to local state etc.
The goal is to decouple the 'detail' if state is local, shared, or global. Also, the source of the state will be abstracted (imagine that some of the state comes from REST/GraphQL/localStorage it doesn't matter)
// written as hooks, but can be written as HOCs as well
const useSomeProp = () => {
const someProp = // ...
return { someProp }
}
const useSomeOtherProp = () => {
const someAction = // ...
const someOtherProp = // ...
const someOtherChildProp = // ...
return { someAction, someOtherProp, someOtherChildProp }
}
const SomeScreen = () => {
const { someProp } = useSomeProp()
const { someAction, someOtherProp, someOtherChildProp } = useSomeChildProp()
return (
<div>
<SomeComponent someProp={someProp} />
<SomeOtherComponent someOtherProp={someOtherProp}>
<SomeOtherChildComponent
someProp={someOtherChildProp}
action={someAction}
/>
</SomeOtherComponent>
</div>
)
}
As you can see, the props now come from 2 hooks (which could be written as HOCs as well)
Now imagine that we want to use the same state of useSomeOtherProp
elsewhere:
const SomeContext = createContext()
const useSomeOtherProp = () => {
const someAction = // ...
const { someOtherProp, someOtherChildProp } = useContext(SomeContext)
return { someAction, someOtherProp, someOtherChildProp }
}
// Wrap the `<SomeContext.Provider value={...state} />` around the `SomeScreen`
Now imagine that SomeOtherComponent
has to become more nested, or used in other places:
const SomeOtherComponent = () => {
// moved from SomeComponent
const { someAction, someOtherProp, someOtherChildProp } = useSomeChildProp()
return (
<div>
<h1>{someOtherProp}</h1>
<SomeOtherChildComponent
someProp={someOtherChildProp}
action={someAction}
/>
</div>
)
}
// Move the <SomeContext.Provider where-ever it makes sense to be able to access the shared state.
Slots
Of course component drilling does not mean only children
, you can also drill components through props ;-)
const SomeOtherComponent = ({ children, left, right }) => (
<div>
<div>{left}</div>
<div>{children}</div>
<div>{right}</div>
</div>
)
// Usage
<SomeOtherComponent
left={<SomeChildComponent1 action={someAction} />}
right={<SomeChildComponent2 ... /> }
>
Some center content
</SomeOtherComponent>
In closing
There's of course more to it, more trade-offs to consider, room for optimizations etc. But we can address them once they become relevant. That's the beauty of a flexible architecture; make things just flexible/plugable enough so that once changed requirements come (and they will), you can respond to them quickly.