βοΈ π‘ react-call
As simple as window.confirm() but it's React:
| window.confirm | react-call |
const message = 'Sure?'
const yes = window.confirm(message)
if (yes) thanosSnap() // π«° |
const props = { message: 'Sure?' }
const yes = await Confirm.call(props)
if (yes) thanosSnap() // π«° |
Present any piece of UI to the user, wait for the response data:
- π¬ Confirmations, dialogs, form modals
- π Notifications, toasts, popups
- π Context menus
- π Or anything!
npm install react-callWe'll setup a confirmation dialog, but you can setup any component to be callable.
import { createCallable } from 'react-call'
interface Props { message: string }
type Response = boolean
export const Confirm = createCallable<Props, Response>(({ call, message }) => (
<div role="dialog">
<p>{message}</p>
<button onClick={() => call.end(true)}>Yes</button>
<button onClick={() => call.end(false)}>No</button>
</div>
))Along with your props, there is a special call prop containing the end() method, which you can use to finish the call and return a response. State, hooks and any other React features are totally fine too.
The Callable itself is the mounting point β it listens to every call and renders the active ones. Place it anywhere visible when making calls, for instance in App.tsx:
+ <Confirm />
// ^-- it will render active callsYou're all done! Now you can do this anywhere in your codebase:
// β response props β
const accepted = await Confirm.call({ message: 'Continue?' })Check out the demo site to see some live examples of other React components being called.
Use React.lazy to code-split callable components and load them on demand.
import { createCallable } from 'react-call'
import { lazy, Suspense } from 'react'
// 1) Lazy-load your component
const Confirm = createCallable(
lazy(() => import('./Confirm')), // default export required
)
// 2) Place the Callable inside a Suspense boundary
export function App() {
return (
<>
{/* Other app UI */}
<Suspense fallback={null}>
<Confirm />
</Suspense>
</>
)
}
// 3) Call it as usual (component is fetched on first call)
const accepted = await Confirm.call({ message: 'Continue?' })Notes:
- Make sure the lazily imported file has a default export (React.lazy requirement).
- Wrap
<Confirm />(or an ancestor) in<Suspense>to handle the loading state. - The lazy component is split into a separate chunk and downloaded only when first called.
The returned promise can be used to end the call from the caller scope:
const promise = Confirm.call({ message: 'Continue?' })
// For example, on some event subscription
onImportantEvent(() => {
Confirm.end(promise, false)
})
// And still await the response where needed
const accepted = await promiseWhile the promise argument is used to target that specific call, all ongoing calls can be affected by omitting it:
// All confirm calls are ended with `false`
Confirm.end(false)The returned promise can also be used to update the call props on the fly:
const promise = Alert.call({ message: 'Starting operation...' })
await asyncOperation()
Alert.update(promise, { message: 'Completed!' })While the promise argument is used to target that specific call, all ongoing calls can be affected by omitting it:
// All alert calls are updated with the new message prop
Alert.update({ message: 'Completed!' })If you need to ensure only one instance of a component is active at a time, use upsert() instead of call(). This is particularly useful for notifications, loading states, or any singleton-like UI:
// First call creates a new instance
const promise1 = Toast.upsert({ message: 'Loading...' })
// Second call updates the existing instance instead of creating a new one
const promise2 = Toast.upsert({ message: 'Almost done...' })
// promise1 === promise2 (same instance)
console.log(promise1 === promise2) // trueThe upsert() method behaves as follows:
- Creates a new instance if no upsert instance is currently active
- Updates the existing upsert instance if one is already active
- Does not affect normal
call()instances - Creates a new instance if the previous upsert instance was ended
// Example: Progress notification that updates itself
const showProgress = async () => {
Toast.upsert({ message: 'Starting download...' })
for (let i = 0; i <= 100; i += 10) {
await new Promise(resolve => setTimeout(resolve, 100))
Toast.upsert({ message: `Progress: ${i}%` })
}
// End the notification
Toast.end(true)
}To animate the exit of your component when call.end() is run, just pass the duration of your animation in milliseconds to createCallable as a second argument:
+ const UNMOUNTING_DELAY = 500
export const Confirm = createCallable<Props, Response>(
({ call }) => (
<div
+ className={call.ended ? 'exit-animation' : '' }
/>
),
+ UNMOUNTING_DELAY
)The call.ended boolean may be used to apply your animation CSS class.
You can also read props from Root, which are separate from the call props. To do that, just add your RootProps type to createCallable and pass them to your Root.
Root props will be available to your component via call.root object.
+ type RootProps = { userName: string }
export const Confirm = createCallable<
Props,
Response,
+ RootProps
>(({ call, message }) => (
...
+ Hi {call.root.userName}!
...
))<Confirm
+ userName='John Doe'
/>You may want to use Root props if you need to:
- Share the same piece of data to every call
- Use something that is availble in Root's parent
- Update your active call components on data changes
Use useMutationFlow from react-call/mutation-flow to wire a confirm button to an async action. The hook manages pending for you and swallows throws so the dialog stays open β the user can retry without losing their place.
import { createCallable } from 'react-call'
import { useMutationFlow, type MutationFn } from 'react-call/mutation-flow'
type Props = { mutationFn: MutationFn<boolean> }
export const Confirm = createCallable<Props, boolean>(
({ call, mutationFn }) => {
const submit = useMutationFlow(call, mutationFn)
return (
<div role="dialog">
<button disabled={submit.pending} onClick={() => submit()}>Yes</button>
<button onClick={() => call.end(false)}>No</button>
</div>
)
},
)
await Confirm.call({
mutationFn: async (call) => {
await api.delete(id) // throws β dialog stays open, pending clears
call.end(true)
},
})The mutationFn receives a narrow { end } view of the call (no RootProps leakage) and decides when β if ever β to close.
If a caller may omit mutationFn, type the prop as optional and chain .orEnd(value) at the callsite. The chain fires only when no mutationFn was provided; with one, it's a no-op.
type Props = { mutationFn?: MutationFn<boolean> }
export const Confirm = createCallable<Props, boolean>(({ call, mutationFn }) => {
const submit = useMutationFlow(call, mutationFn)
return (
// β closes with `true` if no mutationFn
<button disabled={submit.pending} onClick={() => submit().orEnd(true)}>Yes</button>
)
})submit(payload) forwards a typed payload to mutationFn. Because .orEnd lives at the callsite, sibling buttons can chain different values β useful in pickers where the response is the option picked:
type Props = { mutationFn?: MutationFn<'A' | 'B', { choice: 'A' | 'B' }> }
export const Picker = createCallable<Props, 'A' | 'B'>(({ call, mutationFn }) => {
const submit = useMutationFlow(call, mutationFn)
return (
<>
<button onClick={() => submit({ choice: 'A' }).orEnd('A')}>A</button>
<button onClick={() => submit({ choice: 'B' }).orEnd('B')}>B</button>
</>
)
})To let the user dismiss manually when no mutationFn was provided β via a "No" button, click-outside, etc. β omit .orEnd entirely. submit() is a no-op in that case; the dialog stays mounted until something else closes it.
createCallable is Fast Refresh friendly β edits to your callable's source hot-update in place without a full page reload.
If you want the open dialog to survive across saves of its own source, set a displayName on the callable:
export const Confirm = createCallable(({ call, message }) => (
<div role="dialog">
{/* ... */}
</div>
))
+ Confirm.displayName = 'Confirm'Callables without a displayName still HMR β only the dialog you're editing resets; sibling state in the rest of the page is preserved either way.
If you're on Vite, the bundled plugin auto-injects the displayName line so you don't have to write it:
// vite.config.ts
import react from '@vitejs/plugin-react'
import reactCall from 'react-call/vite'
export default {
plugins: [react(), reactCall()],
}With the plugin enabled, every top-level (export) const X = createCallable(...) gets X.displayName = 'X' appended at dev time only β no source change, no production overhead.
<Root> works as a call stack. Multiple calls will render one after another (newer below, which is one on top of the other if your CSS is position fixed/absolute).
No. There can only be one <Root> mounted per createCallable(). Avoid placing it in multiple locations of the React Tree loaded at once, an error will be thrown if so.
You won't need them most likely, but if you want to split the component declaration and such, the public types are available as named exports:
import type { UserComponent, CallContext } from 'react-call'| Type | Description |
|---|---|
| CallFunction<Props?, Response?> | The call() method |
| UpsertFunction<Props?, Response?> | The upsert() method |
| CallContext<Props?, Response?, RootProps?> | The call prop in UserComponent |
| PropsWithCall<Props?, Response?, RootProps?> | Your props + the call prop |
| UserComponent<Props?, Response?, RootProps?> | What is passed to createCallable |
| Callable<Props?, Response?, RootProps?> | What createCallable returns |
| Error | Solution |
|---|---|
| No <Root> found! | You forgot to place the Root, check Rooting section. If it's already in place but not present by the time you call(), you may want to place it higher in your React tree. If you're getting this error on the server see SSR section. |
| Multiple instances of <Root> found! | You placed more than one Root, check Rooting section as there is a warning about this. |
β The react-call setup supports Server Side Rendering. This means both createCallable and Root component are fine if run or rendered on the server.
However, bear in mind that because the call() method is meant to be triggered by user interaction, it is designed as a client-only feature.
Caution
If call() is run on the server a "No <Root> found!" error will be thrown. As long as you don't run the call() method on the server you'll be fine.
Mark the file where you call createCallable(...) as a Client Component (the lib uses useSyncExternalStore):
+ 'use client'
export const Confirm = createCallable(...)Then <Confirm /> mounts cleanly from any Server Component (e.g. app/layout.tsx).