2020-03-14
|~4 min read
|613 words
Update: I wrote a follow up in which I suggested a more idiomatic approach to this problem through deference of control.
React makes it really easy to control a component’s UI. The two most common approaches I’ve seen are to manage the UI completely from an external component’s state via props or self-managed via state.
For the most part, this works really well too. Unfortunately, I came across a situation recently where I wanted to share control.
My component tree looked something like:
<Wrapper>
<Component>
<Optional/>
</Component>
</Wrapper>
Initially, Component
was trusted to entirely manage whether or not Optional
was displayed through the use state:
function Component() {
const [show, setShow] = useState(false)
//...
return (
<div>
{/*...*/}
{show && <Optional />}
</div>
)
}
This worked well until I need a side effect from a click handler affect Component
. My first idea was to use a useEffect
(after all, I’m dealing with side effects). I started by passing in a boolean value externalFlag
like so:
function Component({ externalFlag }) {
const [show, setShow] = useState(false)
useEffect(() => {
setShow((show) => !show)
}, [externalFlag])
//...
return (
<div>
{/*...*/}
{show && <Optional />}
</div>
)
}
If you’re familiar with React life cycles you may already see the problem. Though I specified I wanted the useEffect
to fire on each change of the externalFlag
, this didn’t resolve the fact that it fired on mount! The result was that by the time the component settled, show
was true
and Optional
was visible — not what I wanted!
Setting show
to true
initially in the useState
was an option, but I opted against it. It wasn’t clear why it would be true
- particularly because the desired default behavior is that Optional
doesn’t display - so, even if I wrote a comment explaining the decision, a well-meaning engineer might come along later and change it back and reintroduce the “bug”.
A lot of the trouble stemmed from two facts:
useEffect
requires a change in the variable to fire and in my particular case, the external value was intended to always reset show
to false
(which meant I was always passing false
in and nothing was changing or I was passing in non-sense just to get it to change).To address these concerns, I gave up trying to pass in the value I wanted in Component
to have, deferring all state management to the component itself. Instead, by passing in a count
, I was able to trigger a change. This addresses both concerns: counts increment (in my case), so I could exclude the initial value relatively easily (by excluding a specific value) and have it fire on every subsequent change:
function Component({ count }) {
const [show, setShow] = useState(false)
useEffect(() => {
setShow((show) => !show)
}, [count])
//...
return (
<div>
{/*...*/}
{show && <Optional />}
</div>
)
}
I put together a CodeSandBox to demonstrate this scenario. Check it out and let me know what you think!
The solution I arrived at surely isn’t the most declarative and I’m still thinking about alternatives to this, so if you have a better solution on how to share control of the UI in React components, I’d love to hear it!
That said, I do appreciate this approach because by stepping back I freed myself from self-imposed constraints that the wrapper needed to set the state itself. By trying a different approach, I settled on a solution to allow two components to share responsibility for how the UI is rendered.
Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!