User Selector
An employee picker built on Select. Every instance reads the same cached query, so a form with five approver fields fetches the directory once.
'use client'
import * as React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { UserDirectoryProvider, UserSelector, type UserOption } from '@/components/custom/user-selector'
const DIRECTORY: UserOption[] = [
{ id: 'u1', firstName: 'Ada', lastName: 'Lovelace', employeeNo: 'EMP-001', status: 'ACTIVE' },
{ id: 'u2', firstName: 'Grace', lastName: 'Hopper', employeeNo: 'EMP-002', status: 'ACTIVE' },
{ id: 'u3', firstName: 'Wanjiru', lastName: 'Kamau', employeeNo: 'EMP-014', status: 'ACTIVE' },
{ id: 'u4', firstName: 'Otieno', lastName: 'Odhiambo', employeeNo: 'EMP-021', status: 'SUSPENDED' },
]
// Your real fetcher — a server action, a fetch call, anything returning users.
const fetchUsers = async () => ({ data: DIRECTORY })
export default function UserSelectorDemo() {
const [client] = React.useState(() => new QueryClient())
const [approver, setApprover] = React.useState<string>()
const [reviewer, setReviewer] = React.useState<string>()
return (
<QueryClientProvider client={client}>
<UserDirectoryProvider fetchUsers={fetchUsers}>
<div className="w-full max-w-sm space-y-4">
<UserSelector label="Approver" required value={approver} onChange={setApprover} />
<UserSelector
label="Reviewer"
value={reviewer}
onChange={setReviewer}
// Whoever approves cannot also review.
excludeIds={approver ? [approver] : []}
hideCode
/>
<p className="text-sm text-muted-foreground">
Both selectors share one cached fetch — the directory is loaded once.
</p>
</div>
</UserDirectoryProvider>
</QueryClientProvider>
)
}Installation
pnpm dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/user-selector.jsonnpx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/user-selector.jsonyarn dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/user-selector.jsonbunx --bun shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/user-selector.jsonInstalls the component, select and form-field, and adds @tanstack/react-query.
Setup
Needs a QueryClientProvider above it, and a user source:
'use client'
import { UserDirectoryProvider } from '@/components/custom/user-selector'
import { GetFeederUsers } from '@/lib/api/auth-endpoints'
export function Providers({ children }) {
return <UserDirectoryProvider fetchUsers={GetFeederUsers}>{children}</UserDirectoryProvider>
}fetchUsers may resolve to a bare array or an enveloped { data: [...] }.
Usage
'use client'
import { UserSelector } from '@/components/custom/user-selector'
export function ApprovalChain({ approver, reviewer, setApprover, setReviewer }) {
return (
<>
<UserSelector label="Approver" required value={approver} onChange={setApprover} />
<UserSelector
label="Reviewer"
value={reviewer}
onChange={setReviewer}
excludeIds={approver ? [approver] : []}
/>
</>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Selected user id. |
onChange | (userId: string) => void | — | Required. |
fetchUsers | FetchUsers | — | Overrides the provider for this instance. |
label | string | 'Select User' | Field label. |
excludeIds | string[] | — | Users to leave out. |
hideCode | boolean | false | Drops the employee number from the label. |
activeOnly | boolean | true | Hides non-ACTIVE users. |
allOption | { value: string; label: string } | — | Prepends a sentinel entry. |
staleTime | number | 300000 | Cache lifetime, in milliseconds. |
required / error / errorMessage | Passed through to Select. |
Behaviour notes
The current value survives every filter. A user who is excluded, or has since been deactivated, is still shown while selected — hiding them would leave the field blank where a real answer exists. Deactivated users keep a — SUSPENDED suffix so the state is visible rather than implied.
The "All users" entry is opt-in. Pass allOption={{ value: 'all', label: 'All Users' }} where a filter needs it. A picker that silently injects a pseudo-user into every list is a surprise the one form that wanted it does not justify.
One query key for the whole app. All instances share ['kiota-user-directory'], which is what makes the shared cache work — and what means staleTime should be set consistently.