Expo architecture note

Stop letting Zustand own your components.

Global stores are useful plumbing. They are a bad public API for reusable UI. This guide shows the difference between store-coupled React and the props-first shape we should use in this Expo app.

The concrete problem

Store-coupled

Component imports the data source.

The UI knows Zustand exists. To change the source of truth, you edit the component. To render it in Storybook, you drag app state with it.

const useStore = create((set) => ({
  count: 1,
  inc: () => set((state) => ({ count: state.count + 1 })),
}))

function Counter() {
  const { count, inc } = useStore()

  return (
    <View>
      <Text>{count}</Text>
      <Pressable onPress={inc} />
    </View>
  )
}
Dependency inverted

Component receives a tiny contract.

The UI depends on props. Zustand becomes one adapter, not the component's identity. Storybook can pass args directly.

type CounterProps = {
  count: number
  onIncrement: () => void
}

export function CounterView(props: CounterProps) {
  return (
    <View>
      <Text>{props.count}</Text>
      <Pressable onPress={props.onIncrement} />
    </View>
  )
}

export function CounterContainer() {
  const { count, inc } = useCounterStore()
  return <CounterView count={count} onIncrement={inc} />
}
1Store is allowed at route, feature, or container boundaries.
0Reusable components should import global app stores.
ArgsStorybook should render the visual component with plain props.
HookContainer maps app state into the visual contract.

Why Storybook gets easier

Shape for this Expo app

Keep domain state where it belongs, but make UI components boring to mount. Screens compose containers. Containers call hooks. Components receive explicit props and callbacks.

Screennavigation, layout, feature composition
Container / hookZustand, React Query, SecureStore, API client
View componentprops only, Storybook args, unit tests
src/features/counter/ counter.store.ts # Zustand lives here useCounterModel.ts # maps store/actions into UI contract CounterContainer.tsx # app adapter CounterView.tsx # pure UI CounterView.stories.tsx # args-only Storybook story CounterView.unit.test.tsx # optional UI behavior tests

Container

export function CounterContainer() {
  const count = useCounterStore((s) => s.count)
  const increment = useCounterStore((s) => s.increment)

  return (
    <CounterView
      count={count}
      onIncrement={increment}
    />
  )
}

Story

export default {
  title: 'features/counter/CounterView',
  component: CounterView,
}

export const Default = {
  args: {
    count: 7,
    onIncrement: fn(),
  },
}

Working rules

Rule 01 Stores are adapters.

Use Zustand for shared state, persistence, or cross-screen coordination. Do not make it the interface of visual components.

The store belongs at the edge of a feature. The reusable component should only know the shape it needs to render.

BadMoodCard.tsx
export function MoodCard() {
  const mood = useMoodStore((s) => s.currentMood)
  const save = useMoodStore((s) => s.saveMood)

  return (
    <Card mood={mood} onSave={save} />
  )
}
MoodCard.tsx
type MoodCardProps = {
  mood: MoodOption
  onSaveMood: (mood: MoodOption) => void
}

export function MoodCard(props: MoodCardProps) {
  return <Card mood={props.mood} onSave={props.onSaveMood} />
}
MoodCardContainer.tsx
export function MoodCardContainer() {
  const mood = useMoodStore((s) => s.currentMood)
  const saveMood = useMoodStore((s) => s.saveMood)

  return <MoodCard mood={mood} onSaveMood={saveMood} />
}
MoodCard.stories.tsx
export const Happy = {
  args: {
    mood: { label: 'Calm', value: 'calm' },
    onSaveMood: fn(),
  },
}
Rule 02 Views are dumb on purpose.

A component that can render from Storybook args can also render from tests, mocks, fixtures, previews, and future state libraries.

Dumb does not mean weak. It means the component is honest: all behavior enters through props.

BadChatInput.tsx
export function ChatInput() {
  const send = useChatStore((s) => s.send)
  const isPremium = useUserStore((s) => s.isPremium)

  return <Composer disabled={!isPremium} onSend={send} />
}
ChatInputView.tsx
type ChatInputViewProps = {
  disabled: boolean
  value: string
  onChangeText: (value: string) => void
  onSend: () => void
}

export function ChatInputView(props: ChatInputViewProps) {
  return <Composer {...props} />
}
ChatInputView.stories.tsx
export const DisabledForFreeUser = {
  args: {
    disabled: true,
    value: 'I need to say this...',
    onChangeText: fn(),
    onSend: fn(),
  },
}
ChatInputView.unit.test.tsx
it('calls onSend when enabled', () => {
  const onSend = vi.fn()
  render(
    <ChatInputView
      disabled={false}
      value=""
      onChangeText={vi.fn()}
      onSend={onSend}
    />
  )

  fireEvent.press(screen.getByText('Send'))
  expect(onSend).toHaveBeenCalled()
})
Rule 03 Hooks own translation.

When data shape is ugly, convert it in a hook or container. Keep the component prop contract small, explicit, and domain-readable.

API payloads, cache keys, feature flags, i18n, and persistence details should be converted before they hit the view.

BadCalendarDay.tsx
export function CalendarDay({ date }: { date: string }) {
  const entries = useEntriesQuery(date)
  const locale = useLocale()

  return <Day label={moment(date).locale(locale).format('ddd')} entries={entries.data} />
}
useCalendarDayModel.ts
export function useCalendarDayModel(date: ISODate) {
  const { data = [] } = useEntriesQuery(date)
  const locale = useLocale()

  return {
    label: formatWeekday(date, locale),
    entryCount: data.length,
    status: getDayStatus(data),
  }
}
CalendarDayView.tsx
type CalendarDayViewProps = {
  label: string
  entryCount: number
  status: 'empty' | 'partial' | 'complete'
}

export function CalendarDayView(props: CalendarDayViewProps) {
  return <DayBadge {...props} />
}
CalendarDayView.stories.tsx
export const Complete = {
  args: {
    label: 'Wed',
    entryCount: 3,
    status: 'complete',
  },
}

Use direct store hooks inside screens only when the screen is not reusable and has no Storybook target.

For Storybook, prefer `CounterView.stories.tsx` over stories that mount app providers unless the provider behavior itself is under review.

This is not anti-Zustand. It is anti-singleton-as-component-API.