use-funnel
repository·main·Indexed 20 days ago
https://github.com/toss/use-funnelA React-based state management library for handling complex, multi-step user flows (funnels). It features strong type safety, history-based navigation for intuitive back/forward movement, and support for various routing environments including browser history, react-router, Next.js, and @react-navigation/native. The library provides tools for runtime context validation via Zod or Superstruct and allows for custom router implementation using @use-funnel/core.
What's inside use-funnel
- @use-funnel is a step-by-step state management library optimized for mobile navigation. It is designed to handle complex navigation patterns, such as back/forward transitions in mobile apps, by managing state through a history-based approach. It provides strong type support to validate transitions between steps, ensuring that only valid and required states are requested during the navigation flow.
Manage history and state snapshots with useFunnel
mainUnlike manual state management where history (navigating back/forward) and UI state are often decoupled,
@use-funnelmanages them together.Every time you transition to a new step via
funnel.history.push, the library saves a state snapshot. When a user navigates back in the history,@use-funnelrestores the exact state snapshot associated with that previous step. This ensures that user inputs or selections made in previous steps are preserved correctly when navigating through a multi-step flow.Core concepts of @use-funnel
mainTo use
@use-funneleffectively, you should understand its three primary abstractions:- step: Represents an individual screen or stage within the UI flow (e.g., an input screen for 'Occupation').
- context: Represents the data or values entered during the flow (e.g., the specific occupation value selected by the user).
- history: Represents the complete record of the user's navigation path, including all screens visited and the values entered at each step.
Key features of @use-funnel
main@use-funnel offers several core capabilities for managing complex user flows:
- State Management by History: Manages state transitions through a history stack, making it easy to handle mobile-style back/forward navigation.
- Strong Type Support: Provides type-safe state management that validates transitions between steps, preventing invalid state requests.
- Various Router Support: Optimized for mobile environments with
@react-navigation/nativesupport, while also integrating seamlessly withreact-routerandNext.js.
Core concepts of @use-funnel
mainTo use
@use-funneleffectively, understand these three pillars:- Strong Type Support: The library compares the type of the current step with the next step. This ensures that only the required states/contexts are managed safely, preventing invalid transitions between steps.
- State Management by History: Instead of a single monolithic state,
@use-funnelmanages state based on a history stack. This makes implementing backward and forward navigation (e.g., a 'Back' button) intuitive and consistent. - Various Router Support: It is designed to work across different routing environments, including browser history,
react-router,next.js, and@react-navigation/native.
Compare history.push() vs history.replace()
mainUnderstanding the difference between these two methods is critical for managing history length and back-button behavior.
Feature history.push()history.replace()History entry Adds a new entry Overwrites current entry currentIndex Incremented by 1 No change On back()Moves to previous entry Previous value is already overwritten Primary Use Case Navigating to the next step Saving current step's state or skipping steps Example of skipping a step: If you use
history.push('B')followed byhistory.replace('C'), the history sequence becomesA -> C. Navigating back fromCwill return the user toA, skippingBentirely. This is useful for intermediate steps like loading screens.How useFunnel provides strong type safety for step context
mainIn
@use-funnel, thecontextproperty is dynamically typed based on the currentstep. This means that as you narrow down thestepusing type guards (e.g.,if (funnel.step === 'B')), the TypeScript compiler automatically knows the exact shape offunnel.contextfor that specific step.This solves the problem of "leaky" state requirements where transitioning from Step A to Step B might require properties that were only relevant to Step A, or conversely, failing to provide properties required by Step B.
const funnel = useFunnel<{ A: { a?: string; b?: string }; B: { a: string; b?: string }; }>({ id: "strongly-typed", initial: { step: "A" } }); // When the step is "A", context.a is string | undefined if (funnel.step === "A") { console.log(typeof funnel.context.a); // "string" | "undefined" } // When the step is "B", context.a is strictly string if (funnel.step === "B") { console.log(typeof funnel.context.a); // "string" }Use overlays for modals and bottom sheets
mainThe
funnel.Render.overlaymethod allows you to display UI components like modals or bottom sheets while keeping the previous step visible in the background. This is useful for transient inputs that don't require a full screen transition.When using an overlay, the
renderfunction provides aclosemethod. If the overlay is closed via user interaction (e.g., clicking a backdrop or a cancel button) rather than the browser's back button, you must explicitly callclose()to navigate the funnel history back to the previous step.<funnel.Render PreviousStep={({ history }) => ( <PreviousStep onNext={() => history.push('OverlayStep')} /> )} OverlayStep={funnel.Render.overlay({ render({ history, close }) { return ( <MyModal onNext={(data) => history.push('NextStep', { data })} onClose={() => close()} // Explicitly call close() to navigate back in history /> ); } })} />How to create sub-funnels within a funnel
mainYou can divide a complex funnel into smaller, manageable sub-funnels or reuse specific sequences of steps in different contexts. This is achieved by nesting a new funnel instance inside a step of a parent funnel.
To implement a sub-funnel:
- Inside a component rendered by a parent funnel's step, call
useFunnel()again. - Crucially, provide a unique
idto the sub-funnel configuration to distinguish its history and state from the parent funnel. - Pass necessary data from the parent context to the sub-funnel via props, and use callbacks (like
onNext) to communicate the sub-funnel's completion back to the parent funnel's history.
This approach allows for modularity and prevents the main funnel's type definition from becoming overly bloated with every intermediate step of a sub-process.
import { useFunnel } from '@use-funnel/react-router-dom'; // Parent Funnel const mainFunnel = useFunnel({ id: 'main-funnel', initial: { step: 'A' } }); // Inside a step of the parent funnel (e.g., Step B) // We render a component that manages its own sub-funnel function BStep({ context }) { return <BSubFunnel a={context.a} onComplete={(val) => mainFunnel.history.push('C', { b: val })} />; } // Sub-funnel Component function BSubFunnel({ a, onComplete }) { const subFunnel = useFunnel({ id: 'b-funnel', // Unique ID is required initial: { step: 'B1' } }); return subFunnel.Render({ B1: ({ history }) => <button onClick={() => history.push('B2')}>Next</button>, // ... other steps B3: ({ context }) => <button onClick={() => onComplete(context.someValue)}>Finish Sub-funnel</button> }); }- Inside a component rendered by a parent funnel's step, call
Use @use-funnel with Next.js App Router
mainTo use
@use-funnelwith the Next.js App Router, install the@use-funnel/browserpackage.Note: A dedicated adapter for the App Router is not yet available. You must follow the implementation details provided in the official guide to configure the initial step.
npm install @use-funnel/browser --saveManage multi-step UI state with useFunnel
mainThe
useFunnelhook allows you to define a multi-step workflow where each step has its own unique, strongly-typedcontext. This prevents common errors in manual state management where required properties for future steps are accidentally omitted or incorrectly typed during transitions.When defining a funnel, you provide a generic object where keys are the step names and values are the shape of the
contextfor that step.@use-funnelensures that when you transition to a new step, you provide the exact context required by that step's definition.const funnel = useFunnel<{ A: { a?: string; b?: string }; B: { a: string; b?: string }; }>({ id: "strongly-typed", initial: { step: "A", } }); // Type safety in action: if (funnel.step === "A") { // Transitioning to B requires providing 'a' because it is required in step B's context funnel.history.push("B", { a: "some value" }); }Define and use Transition Events in a funnel
mainTransition events allow you to define and control how a funnel transitions from the current step to the next based on specific conditions or user actions. This is the recommended way to handle multiple paths (branching logic) within a single step, such as handling success vs. failure scenarios.
To implement transition events:
- Define an
eventsobject within the step configuration passed tofunnel.Render.with(). - In the
eventsobject, map event names to handler functions. These handlers receive apayloadand ahistoryobject, allowing you to callhistory.push()to navigate. - In the
renderfunction, use the provideddispatchfunction to trigger the defined events.
Important: Do not attempt to use
historydirectly inside therenderfunction. Always usedispatch('eventName', payload)to trigger the logic defined in youreventsobject.import { useFunnel } from "@use-funnel/next"; const funnel = useFunnel(/* ... */); <funnel.Render EmailInput={funnel.Render.with({ events: { // Email input success event EmailInputSuccess: (email: string, { history }) => { // Transition to the password input step history.push('PasswordInput', { email }); }, // Email input fail event EmailInputFail: (error: Error, { history }) => { // Transition to the error page history.push('ErrorPage', { error: error.message }); } }, render({ context, dispatch }) { return ( <EmailInput email={context.email} // Dispatch EmailInputSuccess event when email input is successful onNext={(email) => dispatch('EmailInputSuccess', email)} // Dispatch EmailInputFail event when email input fails onError={(error) => dispatch('EmailInputFail', error)} /> ); } })} />- Define an