Scenario
There're many times/cases we want to trigger a global component by calling an API, e.g. user picker component, in anywhere. Usually, we need to place the UserPicker
in where we want to show/hide, and add some code for getting selected result. Something like:
{
const [visible, setVisible] = useState(false)
return (
<div>
<Button onClick={() => setVisible(true)} />
<Modal visible={visible} onOK={() => {...}} onCancel={() => setVisible(false)}>
<UserPicker onSelected={(users) => {...}} />
</Modal>
</div>
)
}
But the problem is:
- You need to place
UserPicker
in wherever you need to pick user, similar UI design and similar logic just to fetch the selected users ... that sounds like something reusable. - And even worse, when you pack most of the reusable code into
hooks
, and eventually found thathooks
cannot handle the UI part, but you can't live withouthooks
. It's just like a torture between ice and fire.
We can expose an API fromhooks
for user to call, but we still need to handle the UI component. One of the ways for UI is to use a "global" component, which is just being placed once and can be "shared" everywhere, then we can have what we want, simplified and reusable code. Ok, let's try to figure it out.
How to do it with API?
Here introduce some graceful way to do this in react, and take UserPicker
as an example. The scenario is as below:
There're several places that we need to pick users, and we'd like to trigger this
UserPicker
by a simple API call, and use a callback to fetch the select result.
- Wrap
UserPicker
in top level componentLet's say
TablePage
is the root container that accommodates all the other components, such as buttons, menu... So we can trigger theUserPicker
in any of these components.
{
return (
<div>
<TablePage ... />
<UserPicker ... />
</div>
)
}
- Have an indicator to show/hide
UserPicker
, to resolve the UI part. Ok, let's add some more code.
// TablePage
{
const [show, setShow] = useState(false)
return (
<div>
{children}
<Modal visible={show} onOk={() => { ... }} onCancel={() => setShow(false)}>
<UserPicker ... />
</Modal>
</div>
)
}
- But how to "show" the
Modal
, someone gotta callsetShow(true)
. We can use callback to do it, but that's not gonna be pretty, ugly code ... wired logic ..., we don't want that.
"Global" data is the better way for it. Of course you can use redux/dva/whatever workable. But here we just want to use some "lightweight" method which can be used for inter-components communication, "Context" which is one of native feature provided by react.
I'm not gonna liberate every detail on how to do it, I'm just gonna use some off-the-shelf and place the code here (not all of them). Let's re-design the code a little bit.
// TablePage
{
const { show, setShow } = useModel('ui') // global data getter/setter
return (
<div>
{children}
<Modal visible={show} onOk={() => { ... }} onCancel={() => setShow(false)}>
<UserPicker ... />
</Modal>
</div>
)
}
Wherever user call setShow(true)
will show the UserPicker
and the buttons "OK" and "Cancel" can hide UserPicker
. Seems like we've done the first step, UI part.
- API
What we want is simply calling an API and then trigger and fetch the selected result. Ok, that's pretty straightforward. Let's design the API.
const { show, setShow } = useModel('ui')
type CB = (params: {users: UserInfo[]}) => void
export function showUserPicker(cb: CB) {
setShow(true)
}
We're quite close, but there's still one problem left. How can we get the selected result back? Obviously we need some way to pass our callback to UserPicker
. Use "global" getter/setter again? Maybe, but that doesn't sound like a natural way, because how can we know user has selected and click "OK"? That's something "event" does. Ok, we need to use an event system. Or, anyway that can "notify" you when user actions.
- Event
We're gonna use some off-the-shelf package to do it,useEventEmitter
fromahooks
. Here is the sample code:
export interface Event {
name: string
cb: CB
}
const event$ = useEventEmitter<Event>()
// for the emitter
event$.emit({
name: 'showUserPicker'
cb: (params: { users: UserInfo[] }) => { ... }
})
// for the subscriber
event$.useSubscrption((event) => {
const { name, cb } = event
...
})
And since we want to share the same event$
in between components, so we need to put it into "global" data. Let's say in event
model. We can fetch it by:
const { event$ } = useModel('event')
- Assemble together
Now let's connect all the part together.UI
// TablePage
{
const { show, setShow } = useModel('ui') // global data getter/setter
const { event$ } = useModel('event')
const [selectedUsers, setSelectedUsers] = useState<UserInfo[]>([])
const callback = useRef<any>()
event$.useSubscription((evt) => {
const { name, cb } = evt
callback.current = cb
setShow(true)
}
return (
<div>
{children}
<Modal
visible={show}
onOk={() => {
callback.current?.(selectedUsers)
}}
onCancel={() => setShow(false)}
>
<UserPicker onSelected={ (users) => {
setSelectedUsers(users)
} } />
</Modal>
</div>
)
}
API
type UserPickerCb = (params: { users: UserInfo[] }) => any
export function useUserPicker() {
const { event$ } = useModel('event')
const userPicker = useCallback(
(cb: UserPickerCb): any => {
event$.emit({ name: EVT_SHOW_USER_PICKER, cb })
return true
}, [])
return { userPicker }
}
// caller
userPicker(
({ users }) => {
// fetch "users" after user confirm, then we can do the rest work here
// TODO
}
)
Ok, we've done all the work here. But we can take one more step further to make it more reusable. Say, maybe we have another root container, name ListPage
which probably need to use UserPicker
too, and others ... We definitely don't want to write the similar code again for ListPage
. Let's design a wrapper
to pack the "UI part" for reuse, and name it UserPickerWrapper
.
// UserPickerWrapper.tsx
interface Props {
children: React.ReactNode
}
const UserPickerWrapper: React.FC<Props> = (props) => {
const { children } = props
const [showUserPicker, setShowUserPicker] = useState(false)
const [selectedUsers, setSelectedUsers] = useState<UserInfo[]>([])
const { event$ } = useModel('event')
const callback = useRef<any>()
event$.useSubscription((evt) => {
const { name, cb } = evt
callback.current = cb
console.log('WidgetWrapper useSubscription name:', name)
if (name === EVT_SHOW_USER_PICKER) {
setShowUserPicker(true)
}
})
return (
<div>
{children}
<Modal
visible={showUserPicker}
onOk={() => {
// TODO: pass result to caller
callback.current?.({ users: selectedUsers })
setShowUserPicker(false)
}}
onCancel={() => {
callback.current?.({ users: [] })
setShowUserPicker(false)
}}
>
<UserTree
onSelected={(nodes) => {
const users: UserInfo[] = (nodes as UserDataNode[]).map((node) => ({
emplId: node.emplId,
name: node.name,
avatar: node.avatar,
}))
setSelectedUsers(users)
}}
/>
</Modal>
</div>
)
}
export default UserPickerWrapper
As for TablePage, re-write the code:
export default TablePage {
return (
<UserPickerWrapper>
{ ... }
</UserPickerWrapper>
)
}
As for ListPage, write the code:
export default ListPage {
return (
<UserPickerWrapper>
{ ... }
</UserPickerWrapper>
)
}
Wherever in TablePage or ListPage, just call
showUserPicker(...)
in whatever component and you can showUserPicker
for user to select and get back result for further processing.
And of course, you can extend this to more scenarios.
I guess we can have more coffee time now.