Files
main_frontend/.agents/skills/feature-sliced-design/references/practical-examples.md
T
Daniil 21e936a827
dev / deploy (push) Successful in 2m15s
compute / deploy (push) Has been cancelled
chore: agentic upgrade
2026-05-17 02:11:33 +03:00

534 lines
18 KiB
Markdown

# Practical Examples
Concrete code patterns for common scenarios within FSD structure. Covers
authentication, type definitions, API request handling, and state management
integration (Redux, TanStack Query / React Query).
## Authentication
Auth is one of the most common sources of confusion in FSD. The key question
is: what goes in `shared/`, what goes in `features/` or `pages/`?
### Auth data: `shared/auth/` or `shared/api/`
Tokens, session state, and login utilities are **infrastructure**, not
business logic. Keep them in shared:
```typescript
// shared/auth/token.ts
const TOKEN_KEY = "auth_token";
export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const setToken = (t: string) => localStorage.setItem(TOKEN_KEY, t);
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
// shared/auth/session.ts
export interface Session { userId: string; email: string; role: "admin" | "user" }
// useSession depends on the auth provider (React Context, Zustand, etc.)
export const useSession = (): Session | null => { /* ... */ };
```
The `shared/auth/index.ts` re-exports from these files following the
standard public API pattern.
### Auth UI: pages (single use) or features (multi-use)
Place the login form in the slice that consumes it. Single-use (only on the
login page) goes in `pages/login/`; multi-use (dedicated page + modal login)
goes in `features/auth/`:
```text
pages/login/ ← Single-use
ui/{LoginPage,LoginForm}.tsx
model/login.ts ← Form state, validation
api/login.ts ← POST /auth/login
index.ts
features/auth/ ← Multi-use
ui/{LoginForm,RegisterForm}.tsx
model/auth.ts
api/{login,register}.ts
index.ts
```
### When to use shared/auth vs a user entity
The official Auth guide presents two valid storage locations: **In Shared**
(`shared/auth` or `shared/api`) and **In Entities** (a `user` entity).
Pages and widgets are discouraged.
`shared/auth` is the simpler default. Choose it when the project has no
entities layer yet, or when auth state is just a token plus minimal user info.
A `user` entity is the right call when the project already has an
entities layer **and** auth and profile data are tightly coupled (profile
reused for non-auth purposes like avatars in comments).
```text
// Path A: shared/auth (simpler default)
shared/auth/session.ts ← userId, email, role, token
// Path B: user entity (entities layer exists, profile reuse is real)
entities/user/
model/
current-user.ts ← Current authenticated user + token
user.ts ← Generic user type
api/get-current-user.ts
index.ts
```
For the entity approach, the API client in `shared/api` cannot import from
`entities/`. The official guide describes three solutions: pass the token
manually, expose it through a context with the key kept in `shared/api`,
or inject the token into the API client when the entity store updates.
A `user` entity created **only** to wrap a login response is premature.
See `references/excessive-entities.md` for the full decision matrix.
## Type Definitions
### Where to define types
The location of type definitions follows the same rules as any other code:
| Type scope | Location |
| --- | --- |
| API response/request shapes shared across the app | Domain-named files in `shared/api/` (e.g., `shared/api/product.ts`) |
| Types for a specific entity's domain model | `entities/<name>/model/<name>.ts` |
| Types used only within one page | `pages/<name>/model/<name>.ts` |
| Types used only within one feature | `features/<name>/model/<name>.ts` |
| Generic utility types (e.g., `Nullable<T>`) | Domain-named files in `shared/lib/` (e.g., `shared/lib/nullable.ts`) |
Per Rule 4-4 (domain-based file naming), avoid grouping all types in
`types.ts` or `utils.ts`. A file named `types.ts` cannot answer "types
for what?" without inspection; a file named `product.ts` can.
### Example: API types in shared
```typescript
// shared/api/product.ts: raw API response shapes
export interface ProductDTO {
id: string;
name: string;
price: number;
category: string;
createdAt: string;
}
```
### Example: Domain types in entities
```typescript
// entities/product/model/product.ts: domain model layered on top
import type { ProductDTO } from "@/shared/api/product";
export interface Product extends ProductDTO {
formattedPrice: string;
isOnSale: boolean;
}
export const fromDTO = (dto: ProductDTO): Product => ({
...dto,
formattedPrice: `$${dto.price.toFixed(2)}`,
isOnSale: dto.price < 10,
});
```
**Key principle:** Raw API shapes go in `shared/api/`. Domain models with
business logic go in `entities/`. If you only need the raw shape, do not
create an entity just for types.
## API Request Handling
### Basic pattern: API calls in the consuming slice
```typescript
// pages/product-detail/api/fetch-product.ts
import { apiClient } from "@/shared/api/client";
import type { ProductDTO } from "@/shared/api/product";
export const fetchProduct = (id: string): Promise<ProductDTO> =>
apiClient.get(`/products/${id}`).then((r) => r.data);
```
### Shared API client setup
```typescript
// shared/api/client.ts
import axios from "axios";
import { getToken } from "@/shared/auth/token";
export const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL });
apiClient.interceptors.request.use((config) => {
const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
```
### CRUD helpers in shared
```typescript
// shared/api/create-crud-api.ts
import { apiClient } from "./client";
export const createCrudApi = <T>(resource: string) => ({
getAll: () => apiClient.get<T[]>(`/${resource}`).then((r) => r.data),
getById: (id: string) => apiClient.get<T>(`/${resource}/${id}`).then((r) => r.data),
create: (data: Partial<T>) => apiClient.post<T>(`/${resource}`, data).then((r) => r.data),
update: (id: string, data: Partial<T>) => apiClient.put<T>(`/${resource}/${id}`, data).then((r) => r.data),
remove: (id: string) => apiClient.delete(`/${resource}/${id}`),
});
// Usage: export const productsApi = createCrudApi<ProductDTO>("products");
```
### Request placement rule
Place each request function in the slice that owns the use case:
- **Page-specific data fetching** (e.g., dashboard stats only used on the
dashboard) → `pages/<name>/api/`
- **Feature-specific actions** (e.g., `toggleLike`) → `features/<name>/api/`
- **Reusable domain queries** (e.g., `getUserById`) → `entities/<name>/api/`
- **CRUD primitives** for a generic resource → `shared/api/create-crud-api.ts`
Do not put domain-specific request functions in `shared/api/`. Shared is
infrastructure; the moment a function knows about a specific resource and
its domain rules, it belongs in `entities/` or higher.
## State Management: Redux
### Where a Redux slice belongs
The `from-custom` migration guide draws a clean line: **business
entities** (the things your app works with, like `todo`, `product`, `user`)
go in the Entities layer; **user actions** (`add-todo`, `toggle-todo`,
`like-post`) go in Features.
In v2.1, also remember the pages-first rule: if the slice is used by a
single page, keep it in that page's `model/` segment until reuse appears.
### Business-entity slice in entities
```typescript
// entities/todo/model/todo.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { apiClient } from "@/shared/api/client";
interface Todo { id: string; title: string; completed: boolean }
interface TodoState { items: Todo[]; loading: boolean }
export const fetchTodos = createAsyncThunk("todos/fetch", async () =>
(await apiClient.get<Todo[]>("/todos")).data,
);
const todoSlice = createSlice({
name: "todos",
initialState: { items: [], loading: false } as TodoState,
reducers: {
setCompleted: (state, { payload }: { payload: { id: string; completed: boolean } }) => {
const todo = state.items.find((t) => t.id === payload.id);
if (todo) todo.completed = payload.completed;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => { state.loading = true; })
.addCase(fetchTodos.fulfilled, (state, action) => {
state.items = action.payload;
state.loading = false;
});
},
});
export const { setCompleted } = todoSlice.actions;
export const selectTodos = (state: RootState) => state.todos.items;
export const todoReducer = todoSlice.reducer;
```
The slice's public API re-exports what consumers need:
```typescript
// entities/todo/index.ts
export { todoReducer, selectTodos, setCompleted, fetchTodos } from "./model/todo";
```
**Key:** The entire Redux slice (reducer + selectors + thunks) lives in a
single domain-named file, not split across `reducers.ts`, `selectors.ts`,
`thunks.ts`. That technical-role split reduces cohesion and is an
anti-pattern in FSD.
### User-action slice in features
A user action that orchestrates the entity exposes a hook through its
public API and consumes the entity's reducer:
```typescript
// features/toggle-todo/model/use-toggle-todo.ts
import { useDispatch } from "react-redux";
import { setCompleted } from "@/entities/todo";
export const useToggleTodo = () => {
const dispatch = useDispatch();
return (id: string, current: boolean) =>
dispatch(setCompleted({ id, completed: !current }));
};
```
### Registering slices in app
```typescript
// app/providers/store.ts
import { configureStore } from "@reduxjs/toolkit";
import { todoReducer } from "@/entities/todo";
import { userReducer } from "@/entities/user";
export const store = configureStore({
reducer: {
todos: todoReducer,
user: userReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
```
The store imports each slice's reducer through its public API
(`index.ts`), never reaching into `model/` directly (Rule 4-2). Do not
let individual slices create their own stores.
## State Management: TanStack Query (React Query)
Guidance applies to `@tanstack/react-query` v5 (formerly React Query). The
package name is `@tanstack/react-query`.
### Where to store query keys
Three placements are valid. Choose based on project size and whether the
project already has an Entities layer.
**Option 1: Flat in `shared/api/queries/`** (small projects, few endpoints):
```text
shared/api/
queries/
example.ts
another-example.ts
index.ts ← export { exampleQueries } from './queries/example';
```
**Option 2: Per controller in `shared/api/<controller>/`** (many endpoints):
```text
shared/api/example/
index.ts ← export { exampleQueries } from './example.query';
example.query.ts ← Query factory: keys + functions
get-example.ts
create-example.ts
update-example.ts
delete-example.ts
```
**Option 3: Per entity in `entities/<entity>/api/`** when each request
corresponds to a single entity, and the project already has an Entities
layer. When entities reference each other, see
`references/cross-import-patterns.md` for `@x` notation as a last resort.
### Where to store mutations
Do not mix mutations with queries. Two patterns are accepted:
1. **A mutation hook in the `api/` segment near the place of use.** Use
`setQueryData` for cache updates:
```typescript
// src/pages/example/api/use-update-example.ts
export const useUpdateExample = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, newTitle }) => apiClient.patch(`/posts/${id}`, { title: newTitle }).then((r) => r.data),
onSuccess: (newPost, { id }) => queryClient.setQueryData(POST_QUERIES.detail({ id }).queryKey, newPost),
});
};
```
2. **A `mutationFn` defined in `shared/` or `entities/`** and called from
`useMutation` in the component.
### Query factory pattern
A query factory is an object whose values return query keys. Each key is
wrapped in `queryOptions`, a built-in helper from `@tanstack/react-query` v5
that lets you share `queryKey` and `queryFn` between `useQuery`,
`useSuspenseQuery`, `prefetchQuery`, `setQueryData`, and similar APIs
without rewriting them:
```typescript
// src/shared/api/post/post.queries.ts
import { queryOptions } from "@tanstack/react-query";
import { getPosts, getDetailPost, type DetailPostQuery } from "./get-posts";
export const POST_QUERIES = {
all: () => ["posts"],
lists: () => [...POST_QUERIES.all(), "list"],
list: (page: number, limit: number) => queryOptions({
queryKey: [...POST_QUERIES.lists(), page, limit],
queryFn: () => getPosts(page, limit),
placeholderData: (prev) => prev,
}),
detail: (query?: DetailPostQuery) => queryOptions({
queryKey: [...POST_QUERIES.all(), "detail", query?.id],
queryFn: () => getDetailPost({ id: query?.id }),
}),
};
```
Consume with `useQuery(POST_QUERIES.detail({ id }))`. For pagination,
`placeholderData: prev => prev` prevents UI flicker when navigating pages.
**Benefits of a query factory:** all API requests for a domain live in one
place (readability), every key and query function is reachable through the
same object (convenient access), and refetching is a one-line call
(`queryClient.invalidateQueries({ queryKey: POST_QUERIES.all() })`) without
hunting down keys across the codebase.
### Infinite scroll
Use `infiniteQueryOptions` with `initialPageParam` and `getNextPageParam`.
Add the infinite key to the same factory shown above:
```typescript
import { infiniteQueryOptions } from "@tanstack/react-query";
// Inside POST_QUERIES:
infinite: (limit: number) => infiniteQueryOptions({
queryKey: [...POST_QUERIES.lists(), "infinite", limit],
queryFn: ({ pageParam }) => getPosts(pageParam, limit),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.skip + lastPage.limit < lastPage.total ? lastPage.skip / lastPage.limit + 1 : undefined,
}),
```
Consume with `useInfiniteQuery` and flatten via `data?.pages.flatMap(...)`.
### Suspense mode
`queryOptions` and `useSuspenseQuery` are compatible, and the factory does
not change. Components use `useSuspenseQuery` instead of `useQuery` and skip
`isLoading` entirely. Wrap interested subtrees with an `ErrorBoundary` +
`Suspense` provider in the App layer:
```tsx
// src/app/providers/suspense-provider.tsx
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
export const SuspenseProvider = ({ children }) => (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
</ErrorBoundary>
);
```
### Reading mutation state with useMutationState
`useMutationState` lets any component read the state of a mutation without
passing props, useful for global save indicators. Store mutation keys next
to the query factory:
```typescript
// src/shared/api/post/post.queries.ts
export const POST_MUTATIONS = {
updateTitle: () => ["post", "update-title"],
create: () => ["post", "create"],
};
```
Tag the mutation with `mutationKey`, then read its state from any component:
```tsx
// src/features/update-post/api/use-update-post-title.ts
export const useUpdatePostTitle = () =>
useMutation({
mutationKey: POST_MUTATIONS.updateTitle(),
mutationFn: ({ id, newTitle }) => apiClient.patch(`/posts/${id}`, { title: newTitle }),
});
// src/widgets/save-indicator/ui/save-indicator.tsx
import { useMutationState } from "@tanstack/react-query";
import { POST_MUTATIONS } from "@/shared/api/post";
export const SaveIndicator = () => {
const isPending = useMutationState({
filters: { mutationKey: POST_MUTATIONS.updateTitle(), status: "pending" },
select: (m) => m.state.status,
}).length > 0;
return isPending && <span>Saving...</span>;
};
```
### QueryProvider in the app layer
```tsx
// src/app/providers/query-provider.tsx
import { QueryClient, QueryClientProvider, MutationCache, QueryCache } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { toast } from "sonner";
const queryClient = new QueryClient({
queryCache: new QueryCache({ onError: (e) => toast.error(e.message) }),
mutationCache: new MutationCache({ onError: (e) => toast.error(e.message) }),
defaultOptions: { queries: { staleTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000 } },
});
export const QueryProvider = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
);
```
`QueryCache.onError` and `MutationCache.onError` give one place to wire up
global toast notifications instead of repeating error handling on every hook.
### Code generation
Tools that generate clients from an OpenAPI/Swagger spec are less flexible
than hand-written factories. If your spec is clean and you adopt a generator,
place the generated code in `@/shared/api/`.
### Custom API client
Standardize base URL, headers, and JSON handling in a single class in
`shared/api/`:
```typescript
// src/shared/api/api-client.ts
export class ApiClient {
#baseUrl: string;
constructor(url: string) { this.#baseUrl = url; }
async #handle<T>(response: Response): Promise<T> {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
get = <T>(path: string) => fetch(`${this.#baseUrl}${path}`).then((r) => this.#handle<T>(r));
// post, put, delete follow the same pattern with method/headers/body.
}
export const apiClient = new ApiClient(API_URL);
```
**Key principle:** Place query and mutation hooks in the slice that owns the
domain. Page-specific queries stay in the page. Shared queries go in
`shared/api/` or `entities/<name>/api/` depending on whether the project has
an Entities layer.
## See also
- [Sample project on GitHub](https://github.com/ruslan4432013/fsd-react-query-example)
- [Query options API (tkdodo blog)](https://tkdodo.eu/blog/the-query-options-api)