use-funnel: a type-safe funnel hook for multi-step React flows
A powerful and safe step-by-step state management library at React
At a glance
- What is it?
- use-funnel is a MIT licensed React hook from Toss that models a multi-step screen as a history of typed states, so each step's context is narrowed by the step you are on. It ships separate router adapters for the browser, react-router, Next.js and react-navigation, and it is still on version 0.0.x.
- Who is it for?
- use-funnel suits a React team building a signup, checkout or onboarding wizard where the steps share context and a user can go back, and where the type of the context should depend on the step. It does not suit a project that needs a single screen with a conditional field, where a local state hook is simpler, and it does not suit a team unwilling to maintain a union of step types.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Activity is slowing. The repository last received commits 6 months ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A funnel is a multi-step flow, not a state machine
use-funnel is a React Hook that helps you easily implement complex UI flows, and the project describes it as a powerful and safe step-by-step state management library. The topic tags are funnel, react and wizard-steps.
The problem it addresses is the one every multi-screen form eventually hits. A wizard has steps, and the data collected in an earlier step is often needed in a later one, and the set of valid next steps depends on what has already been entered. Handled with a plain `useState` and a switch statement, this turns into a growing pile of optional fields, where every field is `string | undefined` and the type system can no longer tell you that the school field is only meaningful after the job type was set to student.
The library's answer is to describe the flow as a union of step shapes and to keep a history of them, and it is published by Toss, with the licence line reading MIT, Viva Republica, Inc.
The audience is React developers building flows with more than two steps and more than one branch, in a codebase that is typed. If a flow has a single screen with a conditional field, this library is more machinery than the problem needs.
The context type narrows as the flow advances
The first of the three core concepts is strong type support, and the README states the mechanism: by comparing the type of the current step with the next, you can ensure that only the required states are managed safely.
The example makes it concrete. The step type is declared as a generic parameter, and each step names the shape of its context:
import { useFunnel } from '@use-funnel/browser';
export function App() {
const funnel = useFunnel<{
SelectJob: { jobType?: 'STUDENT' | 'EMPLOYEE' };
SelectSchool: { jobType: 'STUDENT'; school?: string };
SelectEmployee: { jobType: 'EMPLOYEE'; company?: string };
EnterJoinDate: { jobType: 'STUDENT'; school: string } | { jobType: 'EMPLOYEE'; company: string };
Confirm: ({ jobType: 'STUDENT'; school: string } | { jobType: 'EMPLOYEE'; company: string }) & { joinDate: string };
}>({Read the types and the design becomes visible. At the first step the job type is optional, because it has not been chosen. At the step where the school is selected, the context requires `jobType: 'STUDENT'`, so that step is only reachable once the branch has been taken. At the date step, the context is a union of the two branches, each with its own required field. At the confirmation step, the context is the union intersected with the additional `joinDate` field, so a confirmed entry always has the date and one of the two branch values.
This is the part of the library that earns its place. The alternative, which is what most teams end up with, is a context type where every field is optional and a set of runtime checks scattered through the component. The type-level version moves that check to compile time, at the cost of maintaining a union that has to be updated every time a step changes.
History is the state, and routers are adapters
The second core concept is state management by history, described as managing states based on history, which makes it easy to handle backward and forward navigation. The third is various router support: browser history, react-router, next.js, @react-navigation/native, and more.
Those two ideas fit together. If the state of a flow is a stack of history entries rather than a single value, then going back is popping the stack, and the browser's own back button can be wired to the same operation. The router packages exist to make that work with whatever navigation library the application already uses, and the import in the example, from `@use-funnel/browser`, shows the shape of it: the entry point is chosen per router rather than configured at runtime.
The consequence is that the library sits on top of a router rather than replacing one. A project already using Next.js installs the Next.js adapter and the flow participates in the app's routing, so a refresh on step three lands on step three, and the browser back button does what a user expects.
The repository is a pnpm workspace with a `packages/` directory, and the root manifest has a script named `add:router` that runs `hygen packages router`, which is how a new router package is scaffolded. The `examples/` directory holds `nextjs-app-router/` and `nextjs-pages-router/`, which is a useful signal about the intended integration: a funnel inside a Next.js application, with the router being the application's own.
The push call is where the history shows up in practice:
SelectSchool={({ history }) => (
<SelectSchool
onNext={(school) =>
history.push('EnterJoinDate', (prev) => ({
...prev,
school,
}))
}
/>
)}The updater receives the previous context, so a step can add its field to what came before rather than replacing it. That is what lets the confirmation step see both the branch value and the date.
The render API, and the two ways a step can appear
The rendering API puts the steps in as props of a single `funnel.Render` component, and the example shows two different ways of doing that.
One is `funnel.Render.with`, used for the first step. It takes an `events` object whose handlers receive the current context and a `history` object, plus a `render` function that receives a `dispatch` function:
return (
<funnel.Render
SelectJob={funnel.Render.with({
events: {
selectSchool: (_, { history }) => history.push('SelectSchool', { jobType: 'STUDENT' }),
selectEmployee: (_, { history }) => history.push('SelectEmployee', { jobType: 'EMPLOYEE' }),
},
render({ dispatch }) {The naming convention is worth noticing, because it is a convention rather than a rule. A step called `SelectJob` exposes an event called `selectSchool`, so the event names mirror the step names, and the component receives a `dispatch` that triggers them.
The other style is `funnel.Render.overlay`, used for a step that appears on top of the current one rather than replacing it, and it gets a `close` function alongside `history`:
EnterJoinDate={funnel.Render.overlay({
render({ history, close }) {
return (
<EnterJoinDateBottomSheet
onNext={(joinDate) => history.push('Confirm', { joinDate })}
onClose={() => close()}
/>
);
},
})}That is how the example models a bottom sheet: a step that does not replace the page, and a close that dismisses it rather than pushing a history entry backwards. A wizard that has a modal step in the middle is a common enough requirement that having it in the library rather than in application code is the difference between the library working for you and not.
A Toss monorepo with changesets and a code generator
The repository is a pnpm workspace with a `packages/` directory, an `examples/` directory, a `docs/` directory, a `_templates/` directory, and a `.changeset/` directory. The root manifest is private and its scripts are the ones you would expect from a monorepo that publishes several packages at once.
Versioning is handled with changesets. The `changeset` script creates a changeset, `changeset:version` runs `changeset version` and then installs with a lockfile-only update, and `changeset:publish` runs `pnpm publish -r` across the workspace. The `changeset:changelog` dependency is the GitHub changelog generator. The effect for a consumer is that a patch to one router package does not force a version bump on the others, which is the reason a monorepo with per-router packages is the right shape here.
The tooling is otherwise ordinary and well organised. Husky runs lint-staged on the pre-commit hook, and lint-staged applies Prettier to the source and test files and eslint with `--fix` to the TypeScript ones. Prettier is configured with a tab width of 2, a print width of 120, single quotes, semicolons and trailing commas, and eslint has the React and react-hooks plugins, which matters for a library that is entirely a hook. The package manager is pinned to [email protected], and there is a `.nvmrc` and an `.npmrc` at the root.
The documentation is published at use-funnel.slash.page with an English and a Korean version, and the contributing guide is a separate file describing the development process, how to suggest fixes and how to build and test changes.
Version 0.0.12, a last push in April, and what that means
The version numbers are small and the release history is short. The published releases are 0.0.12 on 2025-06-09, 0.0.11 on 2025-02-15 and 0.0.10 on 2024-12-29. The repository is not archived, and the last push was on 2026-04-09.
So the project is still being worked on, and the work is landing on the default branch rather than in a tagged release. For a library whose whole promise is type safety, that distinction is more than cosmetic: the type of a step is part of the API contract, and a change to the generic parameter or to a step's context type is a breaking change for the consumer's type checking even when it is not labelled as one. A 0.0.x version number is the honest signal, and the changesets setup means the granularity of the releases is the consumer's to depend on or not.
For an evaluation, the useful questions are concrete. Which router adapters exist in the `packages/` directory, and does the one you use have a version matching the core? What happens to the context type when the flow grows a new branch, and is the union still readable? And does the behaviour of the history match what your application already does with the browser back button, or does it fight it?
None of those questions are answered by the README, which is a two paragraph summary with a long example. The documentation site and the `examples/` directory are where the answers are, and the example directory naming, a Next.js app router example and a Next.js pages router example, tells you the intended usage is a funnel inside a routed application rather than a standalone widget.
The honest summary is a well-built internal library from a company with a large React codebase, published under MIT, still evolving, and at a version stage where an early adopter should expect to read the source before depending on it.
Editorial conclusion
use-funnel suits a React team building a signup, checkout or onboarding wizard where the steps share context and a user can go back, and where the type of the context should depend on the step. It does not suit a project that needs a single screen with a conditional field, where a local state hook is simpler, and it does not suit a team unwilling to maintain a union of step types. Before adopting, check the router adapter you need exists in the packages directory, read the documentation at use-funnel.slash.page, and note that the newest release is 0.0.12 from 2025-06-09 while the last push was on 2026-04-09, so the published version and the branch differ.
Frequently asked questions
What does use-funnel do in React?
It is a React Hook for implementing complex UI flows, modelling a multi-step screen as a history of typed states. The core concepts are strong type support, state management by history for backward and forward navigation, and support for several routers.
Which routers does use-funnel support?
Browser history, react-router, next.js and @react-navigation/native, and more. The entry point is chosen per router, for example importing `useFunnel` from `@use-funnel/browser`, and the packages live in the `packages/` directory of the repository.
How does use-funnel keep type safety across steps?
You declare a union of step shapes as the generic parameter, so a step's context is only as wide as it needs to be, and the README describes it as comparing the type of the current step with the next to ensure that only the required states are managed safely.
Who maintains use-funnel and under what licence?
It is published by Toss, with the licence line reading MIT, Viva Republica, Inc., and the repository is a pnpm workspace with changesets used for versioning and publishing across the packages.
How recent is the latest use-funnel release?
The newest published version is 0.0.12 from 2025-06-09, before 0.0.11 from 2025-02-15 and 0.0.10 from 2024-12-29. The repository is not archived and the last push was on 2026-04-09, so the branch has moved beyond the last tag.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/toss-use-funnel)