๐Ÿ“ฑ
React NativeExpoMobXUX

Building a 7-Step Onboarding Flow in React Native

โ† All postsยท2026-03-24ยท7 min read

Building a 7-Step Onboarding Flow in React Native

When I started building MCPy โ€” a mobile AI assistant that connects to MCP servers โ€” I knew the first impression had to be great. Onboarding is often the most underrated part of a mobile app. Done well, it sets expectations, collects useful context, and makes the user feel like the app was built for them.

This post is a technical breakdown of the onboarding system I built: a 7-step wizard with animated transitions, persistent state via MobX + AsyncStorage, haptic feedback, and a few unconventional design decisions I made along the way.


The Flow at a Glance

The onboarding consists of 7 steps (indexed 0โ€“6):

StepTypeDescription
0WelcomeBrand intro + CTA
1Text InputName collection
2RadioPrimary use case
3RadioExperience level
4CheckboxInterest topics
5ProcessingAnimated "setup" screen
6CompletionPersonalized welcome

Each step is a full-screen component rendered inside a single Onboarding.tsx screen. There's no navigation stack involved โ€” step transitions are handled purely with animations inside the screen.


Navigation Gating with MobX

The core of the onboarding system is a simple boolean flag in the MobX settings store:

// src/stores/settings.store.ts class SettingsStore { @observable onboardingCompleted = false; @action setOnboardingCompleted(val: boolean) { this.onboardingCompleted = val; } }

This store is persisted to AsyncStorage via mobx-persist-store, so the flag survives app restarts. The navigation gating lives in AppContent.tsx, which is wrapped with MobX's observer():

// src/navigation/AppContent.tsx const AppContent = observer(() => { const { settings } = useStore(); if (!settings.onboardingCompleted) { return ( <Onboarding onComplete={() => settings.setOnboardingCompleted(true)} /> ); } return isAuthenticated ? <RootStack /> : <LoginStack />; });

Because AppContent is an observer, it automatically re-renders the moment onboardingCompleted flips to true โ€” no manual state syncing, no event emitters. The user is instantly moved to the auth screen.


Pre-Auth Onboarding: A Deliberate Choice

One unconventional decision: onboarding runs before authentication, not after.

Most apps gate onboarding behind signup โ€” you register, then personalize. I flipped this. When a new user opens MCPy, they go through the full onboarding before ever seeing a login screen.

The reasoning:

  1. Commitment before friction โ€” Users complete a meaningful interaction before hitting the signup wall. By the time they reach login, they've already invested time and feel ownership over their profile.
  2. Better cold-start experience โ€” Even if someone never creates an account, the welcome experience is polished.
  3. Data available at registration time โ€” The collected preferences are in AsyncStorage, ready to be synced to the backend at signup.

The tradeoff is that returning users who reinstall the app and log in on a fresh device would see onboarding again. This is acceptable given the target audience.


Step Transitions with React Native Reanimated

Transitions between steps use horizontal slides powered by react-native-reanimated. The approach is straightforward: each step is positioned absolutely, and I animate translateX on both the outgoing and incoming step simultaneously.

const SLIDE_DURATION = 220; // ms const slideOut = useSharedValue(0); const slideIn = useSharedValue(width); // starts off-screen right function goToNextStep() { slideOut.value = withTiming(-width, { duration: SLIDE_DURATION }); slideIn.value = withTiming(0, { duration: SLIDE_DURATION }); }

The short 220ms duration keeps the experience snappy. For going back (only enabled on steps 1โ€“4), the directions reverse โ€” the current step slides right, and the previous slides in from the left.


Animated Option Items with Staggered Fade-In

When a step renders its options (radio or checkbox), each item fades in with a stagger based on its index. This creates a cascading effect that feels polished without being distracting.

const STAGGER_DELAY = 70; // ms between each item function OptionItem({ label, index, selected, onPress }) { const opacity = useSharedValue(0); const translateY = useSharedValue(12); useEffect(() => { const delay = index * STAGGER_DELAY; opacity.value = withDelay(delay, withTiming(1, { duration: 200 })); translateY.value = withDelay(delay, withTiming(0, { duration: 200 })); }, []); // ... animated style + haptic on press }

Each selection also triggers a light haptic impact via expo-haptics, giving the interaction a physical dimension that works especially well on iOS.


The Processing Step: Typewriter + Fake Progress

Step 5 is purely theatrical โ€” a processing screen that plays through a sequence of messages while the user waits 3โ€“4 seconds before automatically advancing to completion.

The messages are rendered with a TypewriterText component that animates each character individually:

const LETTER_DELAY = 38; // ms per character function LetterItem({ char, index }) { const opacity = useSharedValue(0); const translateY = useSharedValue(6); useEffect(() => { const delay = index * LETTER_DELAY; opacity.value = withDelay(delay, withTiming(1)); translateY.value = withDelay(delay, withTiming(0)); }, []); return <Animated.Text style={animatedStyle}>{char}</Animated.Text>; }

Messages cycle through:

  • "Analyzing your preferences..."
  • "Building your AI profile..."
  • "Calibrating language models..."
  • "Personalizing your experience..."
  • "Everything's ready."

While this plays out, animated sparkles pulse around the message area and a carousel of user reviews scrolls by โ€” social proof at a moment when the user is primed to feel good about their choice.


SVG Circular Progress Indicator

Steps 1โ€“4 show a circular progress ring to orient the user. Rather than using a third-party library, I built it directly with React Native's Svg component:

function CircularProgress({ step, total }: { step: number; total: number }) { const R = 18; const circumference = 2 * Math.PI * R; const progress = step / total; const animatedDashOffset = useSharedValue(circumference); useEffect(() => { animatedDashOffset.value = withTiming( circumference * (1 - progress), { duration: 300 } ); }, [step]); return ( <Svg width={44} height={44}> {/* Background circle */} <Circle cx={22} cy={22} r={R} stroke="#333" strokeWidth={2.5} fill="none" /> {/* Progress circle */} <AnimatedCircle cx={22} cy={22} r={R} stroke={tintColor} strokeWidth={2.5} fill="none" strokeDasharray={circumference} strokeDashoffset={animatedDashOffset} strokeLinecap="round" rotation="-90" origin="22, 22" /> </Svg> ); }

Animating strokeDashoffset is a classic SVG technique โ€” it's performant because it runs on the UI thread via Reanimated's AnimatedCircle, avoiding JS-thread jank.


Data Storage

The collected data is saved to AsyncStorage at two points:

Name (step 1): Saved immediately when the user continues past the name step.

await AsyncStorage.setItem('@mcpy_user_name', name.trim());

Questionnaire answers (steps 2โ€“4): Saved together when the user finishes the questions.

await AsyncStorage.setItem('@mcpy_onboarding_data', JSON.stringify({ primaryUse: selectedUse, experience: selectedExperience, interests: Array.from(selectedInterests), }));

This data is currently device-local. The backend integration โ€” syncing preferences to the user profile on Supabase at registration time โ€” is the next step. The schema is ready to receive it; I just haven't wired up the write yet.


No Skip Button

One deliberate UX decision: there is no skip button. Users must complete every step to proceed.

This is uncommon. Most apps offer an escape hatch. I chose mandatory completion because:

  • The questions are light (name + 3 quick selections) โ€” the cognitive load is low
  • The collected data has real value for future personalization
  • The processing screen makes the flow feel purposeful, not like busywork

The risk is drop-off. But for an AI assistant app where the target users are already motivated, I'm betting completion rates will hold. I'll revisit with analytics.


What's Next

The onboarding system is feature-complete on the client side. The remaining work:

  1. Sync to Supabase โ€” Write onboarding_data to the user profile during registration
  2. Use the data โ€” Personalize the default system prompt, suggested MCP servers, or UI tone based on experience level
  3. Analytics โ€” Track step-level drop-off to identify friction points

The infrastructure is in place. The onboarding collected its first piece of real signal on day one โ€” now it needs to act on it.