Building a Feature-Based React Module: Posts CRUD with TanStack Query
Organize a React app by feature, not file type, then wire full posts CRUD with TanStack Query and axios, and see exactly where that pattern stops scaling.
This article is published in English.
Most React projects begin with folders named after technical roles: components, hooks, services, types. That layout feels tidy until a single change forces you to open four directories to touch one feature. Feature-based architecture fixes this by grouping code by domain instead of by file type. This article builds a working posts module with create, read, update and delete against the DummyJSON open API, using TypeScript, TanStack Query and axios, and then looks honestly at the problems this structure does not solve.
Two questions architecture patterns answer
React architecture patterns are easier to compare once you notice they address two different questions:
- Where does code live? Feature-based layouts and Feature-Sliced Design (FSD) answer this. They organize code by feature or domain.
- Which way do dependencies point? Clean Architecture answers this. Pure business logic sits at the center, and the outer layers, such as React components and API clients, depend on it rather than the reverse.
These concerns are orthogonal, not competing. You can decide where files go and, independently, decide how they may import one another, which is why the patterns are often combined in real repositories. Feature-based organization is the simplest answer to the first question and a sensible starting point. For a broader survey of layouts, see this comparison of React folder structure patterns.
Why grouping by file type breaks down
Here is the type-based layout many projects start with:
src/
├── components/ # all components
│ ├── PostList.tsx
│ └── PostForm.tsx
├── hooks/ # all hooks
│ ├── usePosts.ts
│ ├── useCreatePost.ts
│ └── useUpdatePost.ts
├── services/ # all API calls
│ └── postsApi.ts
└── types/ # all types
└── post.ts
While the app is small, this looks organized. Now imagine you need to change how editing a post works. The relevant code is spread across components/PostForm.tsx, hooks/useUpdatePost.ts, services/postsApi.ts and types/post.ts. Every change becomes a tour of the tree, and nothing in the structure tells you which files belong to which capability. As features multiply, each folder turns into a long, mixed list, and understanding the blast radius of a change gets harder.
Grouping by feature
The feature-based rule is short: put everything a feature needs, its components, hooks, API calls and types, in one folder. In this example the domain is posts, so the list, detail, create, update and delete logic all go under features/posts/:
src/
├── features/
│ ├── posts/
│ │ ├── api.ts # all posts CRUD
│ │ ├── usePosts.ts # list (Read)
│ │ ├── useCreatePost.ts # Create
│ │ ├── useUpdatePost.ts # Update
│ │ ├── useDeletePost.ts # Delete
│ │ ├── PostList.tsx # list + delete UI
│ │ ├── PostForm.tsx # shared create/edit form
│ │ └── types.ts # post types
│ └── comments/ # other domains grow with the same shape (e.g., comments)
├── shared/
│ ├── api/client.ts # axios instance (baseURL)
│ └── ui/ # shared components used across features
└── app/
├── providers.tsx # QueryClientProvider, etc.
└── main.tsx
Changing the edit flow now means opening one folder. When a new domain such as comments appears, it gets its own folder with the same internal shape. Code that genuinely serves every feature, such as the HTTP client or generic UI primitives, goes in shared/, and application wiring, such as providers and the entry point, goes in app/.
Wiring the posts feature to a real API
To keep the structure concrete, the example talks to DummyJSON, a free REST API that exposes a posts resource (documented at DummyJSON posts docs). Server state is managed with TanStack Query.
One caveat matters for testing: DummyJSON only simulates add, update and delete. It returns a realistic response, including a new ID for created posts, but nothing is stored on the server. The example therefore invalidates the relevant query cache after each mutation to trigger a refetch. Against DummyJSON, that refetch will show the unchanged server data; against a real backend, the changes persist and appear as expected.
The shared HTTP client
Every feature uses one axios instance. It fixes the base URL and is the natural home for cross-cutting behavior implemented as interceptors, such as common error handling or attaching auth headers. The interceptor here is a pass-through placeholder you would extend.
// shared/api/client.ts
import axios from 'axios';
export const client = axios.create({
baseURL: 'https://dummyjson.com',
});
// cross-cutting concerns like common error handling go here as interceptors
client.interceptors.response.use(
(res) => res,
(error) => Promise.reject(error),
);
Types for the posts domain
The feature starts with its types. Post mirrors the shape DummyJSON returns, PostsResponse wraps paginated lists, CreatePostInput describes what creation requires, and UpdatePostInput uses Partial<Pick<...>> so an update may change the title, the body or both.
// features/posts/types.ts
export interface Post {
id: number;
title: string;
body: string;
tags: string[];
reactions: { likes: number; dislikes: number };
views: number;
userId: number;
}
export interface PostsResponse {
posts: Post[];
total: number;
skip: number;
limit: number;
}
export interface CreatePostInput {
title: string;
body: string;
userId: number;
}
export type UpdatePostInput = Partial<Pick<Post, 'title' | 'body'>>;
One API object for all CRUD operations
Next, a single postsApi object groups the five calls: list (with limit and skip for pagination), get by ID, create, update and remove. Each method unwraps res.data, so callers receive typed domain objects rather than axios responses. Note the endpoint for creation is /posts/add, which is specific to DummyJSON.
// features/posts/api.ts — all of CRUD
import { client } from '@/shared/api/client';
import type {
Post,
PostsResponse,
CreatePostInput,
UpdatePostInput,
} from './types';
export const postsApi = {
// Read (list)
list: (limit = 10, skip = 0) =>
client
.get<PostsResponse>('/posts', { params: { limit, skip } })
.then((res) => res.data),
// Read (detail)
get: (id: number) =>
client.get<Post>(`/posts/${id}`).then((res) => res.data),
// Create
create: (body: CreatePostInput) =>
client.post<Post>('/posts/add', body).then((res) => res.data),
// Update
update: (id: number, body: UpdatePostInput) =>
client.put<Post>(`/posts/${id}`, body).then((res) => res.data),
// Delete
remove: (id: number) =>
client.delete<Post>(`/posts/${id}`).then((res) => res.data),
};
Hooks that wrap each operation
Each operation is exposed through a hook. Reads use useQuery; the list hook caches results under the ['posts'] key.
// features/posts/usePosts.ts — Read
import { useQuery } from '@tanstack/react-query';
import { postsApi } from './api';
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: () => postsApi.list(),
});
}
Writes use useMutation. After a successful create, the hook invalidates ['posts'], so any mounted list refetches.
// features/posts/useCreatePost.ts — Create
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { postsApi } from './api';
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: postsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
Delete follows the same pattern, taking the post ID as the mutation variable.
// features/posts/useDeletePost.ts — Delete
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { postsApi } from './api';
export function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => postsApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
An update hook is built the same way: call postsApi.update and invalidate both the list key and the detail key of the affected post. As the number of keys grows, a small query-key factory inside the feature (for example, one object that produces list and detail keys) keeps invalidation consistent and typo-free.
Components that compose the hooks
Components stay thin and simply combine hooks. The list renders titles with a delete button per item and shows a loading message while the query is pending.
// features/posts/PostList.tsx — list (Read) + delete (Delete)
import { usePosts } from './usePosts';
import { useDeletePost } from './useDeletePost';
export function PostList() {
const { data, isLoading } = usePosts();
const deletePost = useDeletePost();
if (isLoading) return <p>Loading…</p>;
return (
<ul>
{data?.posts.map((post) => (
<li key={post.id}>
<span>{post.title}</span>
<button onClick={() => deletePost.mutate(post.id)}>Delete</button>
</li>
))}
</ul>
);
}
The form keeps local state for the title and body and submits a create mutation, disabling the button while the request is in flight. As written it only creates posts; to reuse it for editing, you would accept an optional id prop, prefill the fields and call the update hook when an id is present.
// features/posts/PostForm.tsx — shared create (Create) / edit (Update) form
import { useState } from 'react';
import { useCreatePost } from './useCreatePost';
export function PostForm() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const createPost = useCreatePost();
function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
createPost.mutate({ title, body, userId: 1 });
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
<textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Body" />
<button disabled={createPost.isPending}>
{createPost.isPending ? 'Saving…' : 'Create'}
</button>
</form>
);
}
What matters here is not the individual snippets but their location. Types, API calls, hooks and UI for posts all live inside features/posts/, and nothing outside that folder needs to know how posts work internally.
What the feature-based structure gives you
- Colocation. All code for a capability sits together, so working on posts means opening one folder.
- Easy removal. Deleting a feature is often close to deleting its folder, plus removing a route or two.
- Parallel work. Teams can own features, which reduces merge conflicts and review noise.
- Readable intent. The top-level folder names describe what the application does, an idea often called Screaming Architecture.
A useful habit that strengthens these benefits is giving each feature an index.ts that exports only what other parts of the app may use. Other modules then import from features/posts rather than reaching into its internal files.
Where the pattern stops scaling
Feature-based layout is a strong first step, but three problems appear as the app grows.
Uncontrolled dependencies between features
Real applications add features like comments and profiles. If features/posts imports from features/comments, and comments later imports from posts, you have a circular dependency, and nothing in the structure prevents it. The folder layout says where code lives, not who may depend on whom. You can partly compensate with lint rules such as no-restricted-imports, but the pattern itself provides no rule.
A shared folder that becomes a junk drawer
When developers are unsure where something belongs, it tends to land in shared/. Repeat that often enough and shared/ fills with unrelated, loosely owned code that every feature depends on, which recreates the original type-based sprawl in a single directory.
No notion of layers
Some features are foundational, and others are higher-level compositions of several features, such as a dashboard page. A flat features/ directory cannot express that hierarchy, so there is no structural signal about which direction imports should flow.
In short, feature-based architecture captures the right instinct, grouping by domain, but has no answer to how features should relate. Methodologies such as Feature-Sliced Design address this by adding standardized layers and a rule that imports may only point downward.
Key takeaways
- Separate the two questions: where code lives, and which way dependencies point.
- Grouping by file type scatters a single feature across many folders and hides its boundaries.
- A feature folder colocates components, hooks, API calls and types, making navigation, deletion and team ownership easier.
- Keep
shared/limited to genuinely cross-cutting code, and expose each feature through a small public entry point. - Plan for the pattern's limits: without explicit rules, cross-feature imports, a bloated shared folder and missing layers will eventually appear.