Sharing State Between Parent and Child Components in React: useImperativeHandle vs useShareState Hook
This article examines the common React modal communication problem, compares the conventional controlled‑component callback approach with the useImperativeHandle solution, and introduces a novel pair of hooks (useShareState and useShareValue) that enable seamless state sharing between parent and child components.
Preface
Recently I read a Juejin article titled “React officially does not recommend using useImperativeHandle, but I insist on using it, going against the official recommendation!” that explained how to expose component internal methods using ref and useImperativeHandle . This post, however, focuses on the underlying problem that the solution tries to solve – a common React component communication scenario.
The scenario is a modal component that must be opened by an external button and also be able to close itself after user interaction. A controlled modal cannot close itself, while an uncontrolled modal cannot be opened from outside.
Conventional Solutions
1. Controlled component with callbacks to parent
The parent keeps the open state, and the modal calls a callback when it wants to close.
function
Modal
()
{ ... }
function
App
()
{
const
[show, setShow] = useState(
false
)
const
onModalClose =
()
=>
setShow(
false
)
const
openModal =
()
=>
setShow(
false
)
return
(
<>
<button click={openModal}> open <
/button>
<Modal visible={show} onClose={onModalClose} /
>
<
/>
)
}2. Uncontrolled component exposing methods via useImperativeHandle
The modal manages its own visibility but exposes show and close methods through a ref.
const
Modal = forwardRef(
(
props, ref
) =>
{
const
[visible, setVisible] = useState(
false
)
const
show =
()
=>
setVisible(
true
)
const
close =
()
=>
setVisible(close)
useImperativeHandle(ref,
()
=>
({ show, close }))
return
(
<InnerModal visible={visible}>
<button onClick={close}>close by myself<
/button>
</
InnerModal>
);
}, []);
function
App
()
{
const
modalRef = useRef(
null
)
return
(
<>
<button click={modalRef.current?.show}> open <
/button>
<Modal ref={modalRef} /
>
<
/>
)
}I prefer the second approach because it introduces less intrusion into the parent code.
However, using ref has a risk: if the component has not rendered yet, the exposed methods are unavailable, similar to Vue’s v-if or v-show issues.
I Discovered a “Magical” Solution
Inspired by a post about useControllableValue from ahooks, I experimented with a pair of hooks – useShareState and useShareValue – that allow a parent and child to share and update the same state.
Design Phase
Initially I imagined a simple usage where the parent passes a value to the child, and both can modify it.
function
Parent
()
{
const
[state, setState] = useState(
false
)
return
(
<>
<button onClick={
()
=>
setState(
true
)}> by parent <
/button>
<Children value={state} /
>
<
/>
)
}
function Children (props) {
const { value } = props
const [state, setState] = useState(value)
return (
<button onClick={() => setState(false)} > by myself </
button>
)
}The idea is to merge the parent’s setState with the child’s setState so that calling either updates both.
Combining useShareState & useShareValue
useShareState creates a shared state object { _share: true, value, signalRecord } . useShareValue detects this shape, registers update signals, and ensures all components that share the state re‑render together.
// use-share.ts
import
{ useRef }
from
"react"
;
import
{ useUpdate, useUnmount }
from
"ahooks"
;
type
Signal = ReturnType<
typeof
useUpdate>;
interface
ShareState<T = any> {
_share:
true
;
value: T;
signalRecord: Record<
string
, Signal>;
}
function
isShareState
(
state:
any
):
state
is
ShareState
{
return
state._share ===
true
;
}
function
callSignals
(
state: ShareState
)
{
if
(state.signalRecord ===
null
)
return
;
Object
.values(state.signalRecord).forEach(
(
signal
) =>
signal());
}
function
createSeed
()
{
return
String
(
Math
.random());
}
function
disposeShareValue
(
value: ShareState
)
{
value.signalRecord =
Object
.create(
null
);
}
export
function
useShareState
<
T
>(
defaultValue: T
)
{
const
update = useUpdate();
const
seed = useRef(createSeed());
const
value = useRef<ShareState<T>>({
_share:
true
,
value: defaultValue,
signalRecord:
Object
.create(
null
),
});
Reflect.set(value.current.signalRecord, seed.current, update);
const
setState =
function
(
state: T
)
{
value.current.value = state;
callSignals(value.current);
};
useUnmount(
()
=>
{
disposeShareValue(value.current);
});
return
[value.current, setState];
}
export
function
useShareValue
<
T
>(
value: T | ShareState<T>
)
{
const
seed = useRef(createSeed());
const
update = useUpdate();
const
isShare = isShareState(value);
useUnmount(
()
=>
{
if
(isShare) {
const
shareState = value;
Reflect.deleteProperty(shareState.signalRecord, seed.current);
}
});
if
(isShare) {
const
shareState = value;
Reflect.set(shareState.signalRecord, seed.current, update);
function
setState
(
state: T
)
{
shareState.value = state;
callSignals(shareState);
}
return
[shareState.value, setState,
true
];
}
return
[value,
(
...args:
any
[]
) =>
{},
false
];
}Conclusion
The feature is still experimental and may need additional safeguards, such as supporting getter‑style initialization in useShareState . Feedback is welcome.
— Xekin
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.