Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/typed-flow-render-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@useflow/react": minor
---

Improve Flow render-prop typing so step IDs, next steps, and explicit next/skip targets are inferred from the flow definition and narrowed by the current step.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ function BusinessStep() {
value={context.company || ""} // 💡 TypeScript knows this is a string
onChange={(e) => setContext({ company: e.target.value })}
/>
<button onClick={back}>Back</button>
<button onClick={next}>Continue</button>
<button onClick={() => back()}>Back</button>
<button onClick={() => next()}>Continue</button>
</div>
);
}
Expand Down
1 change: 1 addition & 0 deletions apps/docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export default defineConfig({
{ label: "Branching Flows", link: "/guides/branching-flows" },
{ label: "Flow Variants", link: "/guides/flow-variants" },
{ label: "Persistence", link: "/guides/persistence" },
{ label: "Routing", link: "/guides/routing" },
{ label: "Callbacks", link: "/guides/callbacks" },
{ label: "Custom Layouts", link: "/guides/custom-layouts" },
{
Expand Down
3 changes: 1 addition & 2 deletions apps/docs/src/content/docs/api-reference/flow-component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ const persister = createPersister({ store });
</main>

<footer>
<button onClick={back} disabled={!canGoBack}>
<button onClick={() => back()} disabled={!canGoBack}>
Back
</button>
<button onClick={() => next()} disabled={!canGoNext}>
Expand Down Expand Up @@ -459,4 +459,3 @@ const handleComplete = useCallback((event) => {
- [useFlowState Hook](/api-reference/use-flow-state) - Access flow state
- [FlowProvider](/api-reference/flow-provider) - Global configuration
- [Callbacks Guide](/guides/callbacks) - Event handling

8 changes: 4 additions & 4 deletions apps/docs/src/content/docs/api-reference/storage-stores.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const store = createMemoryStore();

```typescript
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createAsyncStorageStore } from "@useflow/react-native";
import { createAsyncStorageStore } from "@useflow/react";

const store = createAsyncStorageStore(AsyncStorage, {
prefix: "flow" // Optional: custom key prefix
Expand Down Expand Up @@ -100,7 +100,7 @@ import {
} from "@useflow/react";

// Create store
const store = createLocalStorageStore();
const store = createLocalStorageStore(localStorage);

// Create persister with store
const persister = createPersister({ store });
Expand Down Expand Up @@ -147,7 +147,7 @@ describe('MyFlow', () => {
```typescript
const store = process.env.NODE_ENV === 'test'
? createMemoryStore()
: createLocalStorageStore();
: createLocalStorageStore(localStorage);

const persister = createPersister({ store });
```
Expand Down Expand Up @@ -370,4 +370,4 @@ const compressedStore: StorageStore = {

- [createPersister()](/api-reference/create-persister) - Create persisters
- [Persistence Guide](/guides/persistence) - Persistence patterns
- [Custom Stores](/api-reference/custom-stores) - Advanced implementations
- [Custom Stores](/api-reference/custom-stores) - Advanced implementations
6 changes: 3 additions & 3 deletions apps/docs/src/content/docs/api-reference/use-flow-state.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ function ProfileStep() {
return (
<div>
<h2>Welcome, {context.name}!</h2>
<button onClick={back}>Back</button>
<button onClick={() => back()}>Back</button>
<button onClick={() => next()}>Continue</button>
</div>
);
Expand Down Expand Up @@ -257,7 +257,7 @@ function NavigableStep() {

return (
<div>
<button onClick={back} disabled={!canGoBack}>
<button onClick={() => back()} disabled={!canGoBack}>
Back
</button>
<button onClick={() => next()} disabled={!canGoNext}>
Expand Down Expand Up @@ -404,4 +404,4 @@ next({ skipped: true }); // Confusing - is this a skip?
- [Flow Component](/api-reference/flow-component) - The parent component that provides flow context
- [defineFlow](/api-reference/define-flow) - Creating flow definitions
- [Navigation Guide](/core-concepts/navigation) - Detailed navigation patterns
- [Context Guide](/core-concepts/context) - Managing flow state
- [Context Guide](/core-concepts/context) - Managing flow state
15 changes: 12 additions & 3 deletions apps/docs/src/content/docs/core-concepts/navigation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@ title: Navigation
description: Control flow movement with type-safe navigation methods
---

import { Aside } from '@astrojs/starlight/components';

**Navigation** is how users move through your flow. useFlowState provides several navigation methods that are type-safe, flexible, and work seamlessly with your flow definition.

<Aside type="caution" title="React event handlers">
Do not pass flow methods directly as event handlers (for example: `onClick={next}` / `onPress={next}`).
React may pass an event object as the first argument, which can accidentally be treated as a context update.

Always wrap: `onClick={() => next()}` / `onPress={() => next()}` (same for `skip()` and `setContext()`).
</Aside>

## Navigation methods

### `next()` - Move Forward
Expand Down Expand Up @@ -770,8 +779,8 @@ function MyStep() {

return (
<div className="navigation">
{canGoBack && <button onClick={back}>Back</button>}
<button onClick={next}>Continue</button>
{canGoBack && <button onClick={() => back()}>Back</button>}
<button onClick={() => next()}>Continue</button>
</div>
);
}
Expand Down Expand Up @@ -804,7 +813,7 @@ function MyStep() {
return <div>This is the end!</div>;
}

return <button onClick={next}>Continue</button>;
return <button onClick={() => next()}>Continue</button>;
}
```

Expand Down
6 changes: 6 additions & 0 deletions apps/docs/src/content/docs/getting-started/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ A simple 3-step onboarding flow:

Create components for each step. Each component uses the `useFlowState()` hook to access flow state and navigation.

<Aside type="caution" title="React event handlers">
Do not pass flow methods directly as event handlers (e.g. `onClick={next}` / `onPress={next}`). React will pass an event object as the first argument, which can accidentally be treated as a context update.

Always wrap: `onClick={() => next()}` / `onPress={() => next()}` (same for `skip()` and `setContext()`).
</Aside>

#### Welcome Step

```tsx title="WelcomeStep.tsx"
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/getting-started/why-useflow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ function UserTypeStep() {
</form.Field>

{/* useFlow handles multi-step navigation */}
<button type="button" onClick={back}>Back</button>
<button type="button" onClick={() => back()}>Back</button>
<button type="submit">Continue</button>
</form>
);
Expand Down
3 changes: 1 addition & 2 deletions apps/docs/src/content/docs/guides/branching-flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ export function SetupPreferenceStep() {
</label>
</div>

<button onClick={back}>Back</button>
<button onClick={() => back()}>Back</button>
<button onClick={handleContinue} disabled={!context.setupMode}>
Continue
</button>
Expand Down Expand Up @@ -597,4 +597,3 @@ export function SetupPreferenceStep() {
href="/guides/flow-variants"
/>
</CardGrid>

2 changes: 2 additions & 0 deletions apps/docs/src/content/docs/guides/callbacks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ Track every transition for analytics:

Keep URL in sync with current step:

For route-per-step flows (including Expo Router), see the [Routing guide](/guides/routing).

```tsx
import { useRouter } from 'next/router';

Expand Down
9 changes: 4 additions & 5 deletions apps/docs/src/content/docs/guides/custom-layouts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Classic wizard with header, content, and footer:
{/* Footer Navigation */}
<footer className="border-t p-4">
<div className="max-w-4xl mx-auto flex justify-between">
<button onClick={back} disabled={currentIndex === 0}>
<button onClick={() => back()} disabled={currentIndex === 0}>
Back
</button>
<button onClick={() => next()}>
Expand Down Expand Up @@ -433,7 +433,7 @@ export function FlowLayout({
<footer className="border-t p-4">
<div className="max-w-4xl mx-auto flex justify-between">
<button
onClick={back}
onClick={() => back()}
disabled={!canGoBack}
>
Back
Expand Down Expand Up @@ -594,7 +594,7 @@ function WizardLayout({ children }: { children: ReactNode }) {
<header>Step: {stepId}</header>
{children}
<footer>
<button onClick={back}>Back</button>
<button onClick={() => back()}>Back</button>
<button onClick={() => next()}>Next</button>
</footer>
</div>
Expand Down Expand Up @@ -692,7 +692,7 @@ export function OnboardingFlow() {
<div className="flex justify-between mt-8 pt-8 border-t">
{canGoBack ? (
<button
onClick={back}
onClick={() => back()}
className="px-6 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
>
← Back
Expand Down Expand Up @@ -743,4 +743,3 @@ export function OnboardingFlow() {
- [Persistence](/guides/persistence) - Save flow progress
- [Testing](/guides/testing) - Test custom layouts
- [TypeScript](/getting-started/type-safety) - Type-safe layouts

4 changes: 2 additions & 2 deletions apps/docs/src/content/docs/guides/linear-flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -464,8 +464,8 @@ function MyStep() {

return (
<div className="navigation">
{canGoBack && <button onClick={back}>Back</button>}
<button onClick={next}>Continue</button>
{canGoBack && <button onClick={() => back()}>Back</button>}
<button onClick={() => next()}>Continue</button>
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/guides/persistence.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import {
createAsyncStorageStore,
createPersister,
} from "@useflow/react-native";
} from "@useflow/react";

const store = createAsyncStorageStore(AsyncStorage);
const persister = createPersister({ store });
Expand Down
85 changes: 85 additions & 0 deletions apps/docs/src/content/docs/guides/routing.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
title: Routing
description: Patterns for syncing useFlow with your app router (including Expo Router)
---

import { Aside } from '@astrojs/starlight/components';

useFlow is framework-agnostic. When your UI is split across routes (one route per step), you’ll typically keep the **flow state** in useFlow and use your router to show the right screen for the current step.

## Route-per-step pattern

In this architecture:

- Your router owns **what screen is visible**
- useFlow owns **what step is current**, **the path/history**, and **the context**

The simplest integration is **one-way sync**: when the flow transitions, you navigate.

## Expo Router (React Native): one route per step

Mount `<Flow />` in a stable layout (so it doesn’t unmount between step routes), and use `onTransition` to navigate.

```tsx title="app/(onboarding)/_layout.tsx"
import { Flow } from "@useflow/react";
import { router, Stack } from "expo-router";

import { onboardingFlow } from "@/features/onboarding/flow";

type OnboardingStepId = keyof typeof onboardingFlow.config.steps & string;

function getOnboardingRoute(stepId: OnboardingStepId) {
return `/onboarding/${stepId}` as const;
}

export default function OnboardingLayout() {
return (
<Flow
flow={onboardingFlow}
onTransition={({ to, direction }) => {
const route = getOnboardingRoute(to);

if (direction === "backward") {
router.back();
return;
}

router.push(route);
}}
>
{() => (
<Stack
screenOptions={{
headerShown: false,
gestureEnabled: false,
animation: "slide_from_right",
}}
/>
)}
</Flow>
);
}
```

<Aside type="tip" title="Why mount in a layout?">
In the route-per-step pattern, step screens are separate routes. Mounting `<Flow />` in a layout keeps flow state stable as the user navigates between routes.
</Aside>

## Two-way sync (advanced)

Most apps only need Flow → Router sync. Two-way sync (Router → Flow) is only needed for advanced cases:

- Deep linking directly to a mid-flow route
- The user navigates via native back/gesture and you want flow `stepId`/`path` to match
- Restoring a persisted flow state and ensuring the router reflects the restored step

<Aside type="caution" title="Avoid ping-pong loops">
If you sync both directions, prevent infinite loops:

- Flow transition navigates the router
- Router navigation triggers a flow “go to step”
- That triggers another router navigation, etc.

Use a guard (e.g. compare current route vs desired route, or a ref flag) so each direction can no-op when it’s reacting to the other.
</Aside>

2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/guides/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function NavigationTest() {
<div>
<div data-testid="current-step">{stepId}</div>
<button onClick={() => next()}>Next</button>
<button onClick={back}>Back</button>
<button onClick={() => back()}>Back</button>
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/guides/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ function MyStep() {
const { back, canGoBack } = useFlowState();

return (
<button onClick={back} disabled={!canGoBack}>
<button onClick={() => back()} disabled={!canGoBack}>
Back
</button>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ Build a complete onboarding flow with conditional navigation in 3 simple steps:
value={context.company || ""} // 💡 TypeScript knows this is a string
onChange={(e) => setContext({ company: e.target.value })}
/>
<button onClick={back}>Back</button>
<button onClick={next}>Continue</button>
<button onClick={() => back()}>Back</button>
<button onClick={() => next()}>Continue</button>
</div>
);
}
Expand Down
7 changes: 3 additions & 4 deletions apps/docs/src/content/docs/recipes/checkout.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ export function ShippingStep() {
{/* Navigation */}
<div className="flex gap-3">
<button
onClick={back}
onClick={() => back()}
className="px-6 py-2 border rounded-lg hover:bg-gray-50"
>
Back to Cart
Expand Down Expand Up @@ -556,7 +556,7 @@ export function PaymentStep() {
{/* Navigation */}
<div className="flex gap-3">
<button
onClick={back}
onClick={() => back()}
className="px-6 py-2 border rounded-lg hover:bg-gray-50"
>
Back
Expand Down Expand Up @@ -653,7 +653,7 @@ export function ReviewStep() {
{/* Actions */}
<div className="flex gap-4">
<button
onClick={back}
onClick={() => back()}
className="flex-1 px-6 py-3 border rounded"
>
Back to Payment
Expand Down Expand Up @@ -811,4 +811,3 @@ export function CheckoutFlow({ initialCart }: { initialCart: any[] }) {
- [Survey Flow](/recipes/survey)
- [Persistence Guide](/guides/persistence)
- [Testing Guide](/guides/testing)

Loading
Loading