Files
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

18 KiB

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:

// 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/:

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).

// 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

// 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

// 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

// 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

// 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

// 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

// 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:

// 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:

// 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

// 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):

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):

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:

    // 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:

// 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:

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:

// 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:

// 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:

// 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

// 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/:

// 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