Frontend Development 19 min read

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.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Sharing State Between Parent and Child Components in React: useImperativeHandle vs useShareState Hook

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

frontendreactstate sharingCustom HooksuseImperativeHandle
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.