Zustand Store Separation by FSD Slice and Client State Isolation Pattern in the widgets Model
Once a frontend project grows beyond a certain size, the single global Zustand store that once looked clean suddenly ends up holding dozens of keys. I've experienced this myself in production — I remember a widget subscribing to 20 keys in the global store, causing completely unrelated components to re-render on every tab switch. PR conflicts were frequent, and every time a new team member asked "where does this state get changed?", it took a fair amount of time to explain.
In this article, we'll walk through the pattern of separating stores by slice in Feature-Sliced Design (FSD) and isolating client UI state using the model segment of the widgets layer, with concrete code examples. Once you apply the principle of each slice owning its own store and exposing it to the outside only through a Public API, PR conflicts noticeably decrease and new team members get up to speed much faster.
This is written for frontend developers who are already using Zustand and are curious about or just starting to adopt FSD. Even if you're new to FSD, the core concepts section covers the background knowledge you'll need.
Core Concepts
FSD's Three-Tier Layer Structure
FSD divides frontend code into three levels: layer → slice → segment.
app/
pages/
widgets/
features/
entities/
shared/Layers can only depend downward. widgets can reference features and entities, but the reverse is a rule violation. Direct references between slices within the same layer are also prohibited in principle.
FSD v2.1 cross-import: The
@xnotation has been officially incorporated as a standard for cases where cross-slice references are unavoidable (e.g.,entities/user/@x/product). However, this is an exceptional allowance, not the default pattern. Routine direct imports between slices in the same layer are still discouraged.
A slice is a folder divided by domain unit within the same layer. One business concept becomes one slice — like
user,product, orcheckout.
Within each slice, code is divided into segments by purpose.
| Segment | Role |
|---|---|
ui |
React components responsible for rendering |
model |
State (Zustand store), selectors, custom hooks, types |
api |
Server communication functions |
lib |
Utilities, helpers |
What the model Segment Does
The core role of the model segment is separating state logic from UI. Components in the ui segment focus solely on rendering, while model is responsible for where state comes from and how it gets updated.
widgets/dashboard-panel/
model/
store.ts ← Zustand store definition
selectors.ts ← Derived state computation
hooks.ts ← Custom hooks for UI connection
ui/
DashboardPanel.tsx
index.ts ← Public API (interface exposed to the outside)The Public API is the interface exposed to the outside through a slice's
index.ts. You can freely change the internal implementation — you only need to keep what this file exports stable.
The Principle of Per-Slice Store Ownership
Honestly, at first you might think "can't I just put everything in one store?" And for a small project, that might actually be simpler. But separating stores by slice gives you important properties:
- Store actions and selectors are only exposed externally through the slice's
index.ts - Changing internal implementation doesn't affect other slices as long as the Public API is maintained
- Thanks to Zustand's selector-based subscriptions, unrelated state changes don't trigger re-renders
It's fine if the number of stores grows. In fact, it's something to welcome.
Practical Application
Example 1: Creating a Dashboard Panel Store in the widgets Layer
Let's say we have a dashboard panel widget. Which tab is active, whether the panel is collapsed — these UI states are unrelated to the server and are only needed within this widget. When mixed into a global store, switching tabs causes completely unrelated components to re-render. Separating into a widget-specific store clearly narrows this scope.
// widgets/dashboard-panel/model/store.ts
import { create } from 'zustand'
interface DashboardPanelState {
activeTab: string
isCollapsed: boolean
setActiveTab: (tab: string) => void
toggleCollapse: () => void
}
export const useDashboardPanelStore = create<DashboardPanelState>((set) => ({
activeTab: 'overview',
isCollapsed: false,
setActiveTab: (tab) => set({ activeTab: tab }),
toggleCollapse: () => set((s) => ({ isCollapsed: !s.isCollapsed })),
}))Separating selectors into their own file makes them much more reusable than writing selectors inline in each component. The standard here is to put only derived values that read state in the selector file, and source actions (update functions like toggleCollapse) directly from the store — this makes the separation of concerns clear.
// widgets/dashboard-panel/model/selectors.ts
import { useDashboardPanelStore } from './store'
export const useActiveTab = () =>
useDashboardPanelStore((s) => s.activeTab)
export const useIsCollapsed = () =>
useDashboardPanelStore((s) => s.isCollapsed)Components simply import and use hooks from the model segment.
// widgets/dashboard-panel/ui/DashboardPanel.tsx
import { useActiveTab, useIsCollapsed } from '../model/selectors'
import { useDashboardPanelStore } from '../model/store'
export const DashboardPanel = () => {
const activeTab = useActiveTab()
const isCollapsed = useIsCollapsed()
// Actions come directly from the store — the boundary between reads (selectors) and writes (store actions)
const toggleCollapse = useDashboardPanelStore((s) => s.toggleCollapse)
return (
<div>
<button onClick={toggleCollapse}>
{isCollapsed ? 'Expand' : 'Collapse'}
</button>
{!isCollapsed && <TabContent activeTab={activeTab} />}
</div>
)
}Finally, index.ts selectively exposes only what should be visible externally. The key is to hide the store itself.
// widgets/dashboard-panel/index.ts
export { DashboardPanel } from './ui/DashboardPanel'
// store and selectors are not exposed here
// This widget's UI state is consumed only within the widget| File | Role | Externally Exposed |
|---|---|---|
model/store.ts |
Zustand store definition | ❌ |
model/selectors.ts |
Derived state selectors | ❌ |
ui/DashboardPanel.tsx |
Rendering component | ✅ (via index.ts) |
Example 2: Using the Same Widget as Multiple Instances — Store Factory Pattern
There are cases where you need to place the same panel widget multiple times on a dashboard, each with independent state. A single store created with create() is shared across all instances, so switching tabs in panel A would be reflected in panel B as well.
This can be cleanly solved by combining Zustand's createStore() — the vanilla API that directly manages store instances without React hooks — with React Context. Since the Context Provider injects an independent store instance into each subtree, each panel has completely separate state.
// widgets/dashboard-panel/model/store.ts
import { createStore } from 'zustand/vanilla'
import { useStore } from 'zustand'
import { createContext, useContext } from 'react'
interface DashboardPanelState {
activeTab: string
setActiveTab: (tab: string) => void
}
// createStore() is the API for directly managing store instances without React hooks
// It can inject instances into Context, making it suitable for multi-instance isolation
export const createPanelStore = (initialTab = 'overview') =>
createStore<DashboardPanelState>((set) => ({
activeTab: initialTab,
setActiveTab: (tab) => set({ activeTab: tab }),
}))
export type PanelStore = ReturnType<typeof createPanelStore>
export const PanelStoreContext = createContext<PanelStore | null>(null)
export const usePanelStore = <T>(selector: (s: DashboardPanelState) => T): T => {
const store = useContext(PanelStoreContext)
if (!store) throw new Error('PanelStoreContext.Provider is required')
return useStore(store, selector)
}With the factory pattern, createPanelStore and PanelStoreContext need to be used externally, so the Public API in index.ts changes accordingly. While Example 1 hid the store entirely, here we selectively expose only what's needed for instance creation and injection.
// widgets/dashboard-panel/index.ts (after applying the factory pattern)
export { DashboardPanel } from './ui/DashboardPanel'
// Expose factory and Context for multiple instances
export { createPanelStore, PanelStoreContext } from './model/store'
export type { PanelStore } from './model/store'
// usePanelStore is an internal widget hook, so it's still not exposedOn the consuming side, it looks like this:
// pages/dashboard/ui/DashboardPage.tsx
import { createPanelStore, PanelStoreContext, DashboardPanel } from 'widgets/dashboard-panel'
import type { PanelStore } from 'widgets/dashboard-panel'
// Types are explicit, so the Provider value and store instance types are automatically guaranteed
const panelA: PanelStore = createPanelStore('analytics')
const panelB: PanelStore = createPanelStore('reports')
export const DashboardPage = () => (
<div>
<PanelStoreContext.Provider value={panelA}>
<DashboardPanel />
</PanelStoreContext.Provider>
<PanelStoreContext.Provider value={panelB}>
<DashboardPanel />
</PanelStoreContext.Provider>
</div>
)Since each Provider subtree references an independent store instance, changing the tab in panelA has no effect whatsoever on panelB.
Example 3: Deciding Where to Place Each Type of State
"Where should I put this state?" — this is the most common question in practice. Here are some criteria to help decide:
| State Type | Location | Examples |
|---|---|---|
| Domain data fetched from the server | entities/*/model/ or TanStack Query |
User profile, product list |
| User input state for a specific feature | features/*/model/ |
Filter selections, search terms |
| Widget-internal UI state | widgets/*/model/ |
Tab activation, expand/collapse, drafts |
| App-wide settings | app/model/ |
Theme, auth token |
Looking at a typical misplacement makes the criteria clearer. Sometimes "dashboard panel tab activation state" ends up in the entities layer like this:
// ❌ Wrong location — entities is the layer for domain data
// entities/panel/model/store.ts
export const usePanelUIStore = create<{ activeTab: string }>()((set) => ({
activeTab: 'overview',
setActiveTab: (tab) => set({ activeTab: tab }),
}))This state is pure UI state unrelated to the server, so it belongs in widgets/dashboard-panel/model/store.ts. entities is the layer for domain data like user profiles or product lists.
Recommended TanStack Query separation: For data fetched from the server, managing it with TanStack Query rather than Zustand is the recommended pattern in FSD. This keeps Zustand responsible only for pure client state, making roles clear.
Pros and Cons Analysis
Advantages
The first thing I felt after adopting this pattern in practice was that PR conflicts decreased. Since slices are independent, two team members working on different widgets won't have overlapping store files.
| Item | Details |
|---|---|
| Clear boundaries | UI components focus solely on rendering, while model takes full responsibility for state logic |
| Parallel development | Slices are independent, so multiple team members can work simultaneously without conflicts |
| Predictable dependencies | One-way top→bottom dependencies reduce unexpected side effects |
| Testability | Only the Public API needs to be imported, so internal refactoring doesn't affect tests |
| Re-render optimization | Selector-based subscriptions prevent unrelated state changes from triggering re-renders |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Initial learning curve | It takes time for the whole team to learn the FSD + slice store pattern | Using @feature-sliced/eslint-plugin to automatically detect rule violations makes onboarding smoother |
| Sharing state between stores | Coordination logic becomes complex when state from two slices needs to be used together | Compose at a higher layer (page), or lift shared state up to shared or entities |
| Risk of over-separation | Applying this to a small project can introduce unnecessary complexity | Assess team size and domain complexity first before deciding whether to adopt |
| Persistence caution | Injecting the persist middleware from outside creates hidden coupling |
Persistence configuration should always be handled only within the slice's model/store.ts |
Hidden coupling refers to dependencies that arise without being explicitly stated in the code. Wrapping the
persistmiddleware externally makes it hard to track where and how the store is being persisted.
Most Common Mistakes in Practice
-
Re-exporting the entire store verbatim in
index.ts— Writingexport * from './model/store'exposes all internal implementation and defeats the purpose of the Public API. It's important to selectively export only what external consumers actually need. Just like Example 1 hid the store and only exposed the component. -
Directly referencing slices within the same layer — Directly importing the store from
widgets/sidebarinsidewidgets/headeris a violation of FSD rules. State that needs to be shared must be moved down to a lower layer (entitiesorshared).@feature-sliced/eslint-pluginautomatically catches this violation. -
Storing server data in a Zustand store — Putting fetched data into Zustand means you have to manage loading, error, and caching logic yourself. Dividing responsibilities with TanStack Query so that Zustand handles only pure client state makes the code much simpler. I also experienced my store ballooning in the early days when I kept this boundary blurry.
Closing Thoughts
The essence of separating Zustand stores by slice in FSD is creating boundaries where each slice owns its state and communicates with the outside only through the Public API.
There are three steps you can start with right now:
-
Pick one widget UI state from your existing global store and try separating it — Start with something unrelated to the server, like tab activation or modal open state, to keep the risk low. You can create
model/store.tsinside the widget folder and define the store withcreate(). -
Clean up the slice's
index.tsas a Public API — If your currentindex.tsexposes everything withexport *, try removing everything except what's actually needed externally. In this process, unnecessarily exposed internal implementations will become visible. -
Add
@feature-sliced/eslint-pluginto your project — After installing, configure it as shown below to automatically detect layer dependency direction violations.
pnpm add -D @feature-sliced/eslint-plugin// eslint.config.js
import fsd from '@feature-sliced/eslint-plugin'
export default [
...fsd.configs.recommended,
]Once rule violations start being caught in CI, the whole team will naturally internalize FSD principles.
References
- Zustand: The Minimalist State Architecture | Feature-Sliced Design 공식 블로그
- Layers | Feature-Sliced Design 공식 문서
- FSD v2.1 "Pages come first!" 릴리즈 노트
- Migration from v2.0 to v2.1 | Feature-Sliced Design
- Slices Pattern | Zustand 공식 문서
- zustand-slices | GitHub
- When should we use multiple stores instead of a single store with separate slices? | Zustand GitHub Discussion
- Secrets of a Scalable Component Architecture | Feature-Sliced Design