How to Fix Duplicate Post on Update in Redux Toolkit
Fix duplicate posts when updating with Redux Toolkit. Stop double dispatches, use a proper update reducer, and switch to createEntityAdapter for robust CRUD.
Why does updating a post in Redux Toolkit create a duplicate entry instead of replacing the existing one?
I’m building a CRUD application using Redux Toolkit for managing posts. The create and read operations work fine, but the update operation results in the updated post appearing twice in the list instead of replacing the original.
Here’s the relevant code from my postSlice.js:
export const postSlice = createSlice({
name: "post",
initialState,
reducers: {
update: (state, action) => {
const previousPosts = state.entries.filter(
(entry) => entry.id !== action.payload.id
);
state.entries = [action.payload, ...previousPosts];
},
}
});
In EditPost.jsx:
const EditPost = () => {
const postId = useParams().id;
const posts = useSelector((state) => state.post.entries);
const selectedPost = posts.find((post) => post.id === postId);
return (
<div>
<h2>This is Edit Post</h2>
<PostForm selectedPost={selectedPost} />
</div>
);
};
And in PostForm.jsx (handling both create and update):
const PostForm = ({ selectedPost }) => {
const navigate = useNavigate();
const [formData, setFormData] = useState({
id: selectedPost?.id || "",
name: selectedPost?.name || "",
desc: selectedPost?.desc || "",
author: selectedPost?.author || "",
});
const dispatch = useDispatch();
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
};
const handleSubmit = (e) => {
e.preventDefault();
if (selectedPost) {
const updatePost = {
id: formData.id,
name: formData.name,
desc: formData.desc,
author: formData.author,
};
dispatch(update(updatePost));
navigate("/");
}
const newPost = {
id: nanoid(),
name: formData.name,
desc: formData.desc,
author: formData.author,
};
dispatch(create(newPost));
setFormData({
name: "",
desc: "",
author: "",
});
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="name"
placeholder="Name"
value={formData.name}
onChange={handleChange}
/>
<input
type="text"
name="desc"
placeholder="Desc"
value={formData.desc}
onChange={handleChange}
/>
<input
type="text"
name="author"
placeholder="Author"
value={formData.author}
onChange={handleChange}
/>
<button type="submit">{selectedPost ? "Update" : "Create"}</button>
</form>
);
};
export default PostForm;
After creating a post and then updating it, the updated version appears as a duplicate in the list. How can I fix the update reducer to properly replace the existing post without creating duplicates? Any suggestions for improving the Redux Toolkit slice for CRUD operations would be helpful.
Redux Toolkit update redux duplicates usually come from dispatching both an update and a create (or from id/type mismatches), so your updated post gets inserted instead of replacing the original. In your code the form always calls create(…) after update(…), which produces the duplicate; fix the component dispatch logic and make the slice update replace an existing item (use findIndex + assignment or normalize with the createEntityAdapter helpers).
Contents
- Symptom — duplicate post after update (Redux Toolkit)
- Why updates end up as duplicates
- Immediate fix — change the component dispatch logic
- Fixing the Redux Toolkit slice reducer (safe replace patterns)
- Use createEntityAdapter for robust CRUD
- ID typing, selectors and extra checks
- Tests and best practices to prevent duplicates
- Sources
- Conclusion
Symptom — duplicate post after update (Redux Toolkit)
You create a post, then open an edit form and submit — the updated post appears twice in the list. Looking at your PostForm.handleSubmit, the create(…) dispatch is executed unconditionally (it sits after the update branch), so every submit creates a new post even while you also dispatch update(…). That’s the immediate cause of the duplicate.
Before changing reducer logic, check whether your UI is sending two actions. Open the Redux DevTools and look at which actions run when you hit Submit.
Why updates end up as duplicates
There are three common reasons this happens:
- Component-level bug: you dispatch both update and create for the same submit (your code does exactly that).
- ID/type mismatch: compare by strict equality (===) between a string route param and a numeric id will fail, so the app thinks the entity is “new” and creates another one.
- Reducer pattern mistakes: with createSlice you must either mutate the draft state or return a new state object — reassigning the local state variable inside a reducer does nothing. The Redux Toolkit docs on Immer reducers explain the allowed patterns, and the Redux immutable update patterns guide shows the pitfalls.
There are also adapter-specific edge cases (for example, changing an entity’s id can replace/merge other entities); see the createEntityAdapter docs and this repo issue showing duplicate-id behavior when IDs collide: https://github.com/reduxjs/redux-toolkit/issues/2018.
Immediate fix — change the component dispatch logic
The fastest, correct fix is to stop dispatching create when you intend to update. Move the create(…) call into an else block (or return early after update). Updated handleSubmit:
const handleSubmit = (e) => {
e.preventDefault();
if (selectedPost) {
const updatePost = {
id: formData.id,
name: formData.name,
desc: formData.desc,
author: formData.author,
};
dispatch(update(updatePost));
navigate("/");
return; // prevents the create(...) below from running
}
const newPost = {
id: nanoid(),
name: formData.name,
desc: formData.desc,
author: formData.author,
};
dispatch(create(newPost));
setFormData({
name: "",
desc: "",
author: "",
});
navigate("/");
};
Or use if/else:
if (selectedPost) {
dispatch(update(updatePost));
} else {
dispatch(create(newPost));
}
navigate('/');
After this change, the duplicate caused by a second create action will stop.
Fixing the Redux Toolkit slice reducer (safe replace patterns)
Your reducer currently filters out the old item then prepends the updated payload:
const previousPosts = state.entries.filter(
(entry) => entry.id !== action.payload.id
);
state.entries = [action.payload, ...previousPosts];
That can work if ids match and if only update is dispatched, but it’s safer and more explicit to either:
- Replace the item in place (preferred, O(1) mutation with Immer), or
- Map over the array and swap the matching item.
Mutating draft example (handles type normalization):
update: (state, action) => {
const id = String(action.payload.id);
const index = state.entries.findIndex(e => String(e.id) === id);
if (index !== -1) {
// Replace the existing entry
state.entries[index] = action.payload;
} else {
// If it doesn't exist, insert (or ignore, depending on your logic)
state.entries.unshift(action.payload);
}
}
Map-based replacement (pure-return style):
update: (state, action) => {
const id = String(action.payload.id);
return {
...state,
entries: state.entries.map(e =>
String(e.id) === id ? action.payload : e
),
};
}
Important rules:
- Do not reassign the
statevariable (e.g., avoidstate = newStateinside a reducer). If you want to replace the slice state, return the new object from the reducer — otherwise mutate the draft fields (e.g.,state.entries = ...orstate.entries[index] = ...). See discussion on returning vs mutating in this Q&A example: https://stackoverflow.com/questions/60002846/how-can-you-replace-entire-state-in-redux-toolkit-reducer and the Immer usage docs.
Also normalize id types (use String(…) when comparing) to avoid mismatches between route string ids and numeric ids stored in state.
Use createEntityAdapter for robust CRUD (Redux Toolkit)
For collections, the built-in adapter standardizes shape and gives you battle-tested reducers:
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
const postsAdapter = createEntityAdapter();
const initialState = postsAdapter.getInitialState();
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
addPost: postsAdapter.addOne,
// updateOne expects { id, changes } and merges changes into the entity
updatePost: (state, action) =>
postsAdapter.updateOne(state, { id: action.payload.id, changes: action.payload }),
// upsertOne will add if missing or update if present (handy if you sometimes create client-side)
upsertPost: (state, action) => postsAdapter.upsertOne(state, action.payload),
removePost: postsAdapter.removeOne,
setAllPosts: postsAdapter.setAll,
},
});
export const { addPost, updatePost, upsertPost, removePost, setAllPosts } = postsSlice.actions;
export default postsSlice.reducer;
export const postsSelectors = postsAdapter.getSelectors(state => state.posts);
The adapter also gives selectors like selectAll, selectById, which reduce bugs introduced by manual array operations. The adapter docs are here: https://redux-toolkit.js.org/api/createEntityAdapter
Note: updateOne will silently ignore an update if there is no entity with the provided id; upsertOne is useful if you want to insert when missing.
ID typing, selectors and extra checks
- Matching IDs: when you read route params via useParams(), you get strings. Either store IDs as strings (nanoid does that) or convert the param to number when appropriate:
const postId = Number(useParams().id)or compare withString(entry.id) === postId. - Selected item lookup: use a selector or the adapter’s
selectByIdto avoid repeating logic. - DevTools: when debugging duplicates, open Redux DevTools and inspect the actions — if you see both update and create dispatched, fix the UI logic first.
Tests and best practices to prevent duplicates
- Single responsibility: a form should either create or update, not both. Use separate action flows or guard with
if/else. - Normalize state: use createEntityAdapter for add/update/remove helpers and selectors.
- Standardize ID type across client/server boundaries.
- Unit test the reducer: test update replaces an entity, and create does not run simultaneously.
- Log actions during development (or step through with DevTools) to catch accidental double-dispatches.
- If your server returns the canonical saved object (with server id), prefer using that returned object to replace local state.
Sources
- https://redux-toolkit.js.org/api/createEntityAdapter
- https://redux-toolkit.js.org/usage/immer-reducers
- https://redux-toolkit.js.org/usage/usage-guide
- https://redux.js.org/usage/structuring-reducers/immutable-update-patterns
- https://github.com/reduxjs/redux-toolkit/issues/2018
- https://stackoverflow.com/questions/60002846/how-can-you-replace-entire-state-in-redux-toolkit-reducer
- https://stackoverflow.com/questions/76714090/redux-toolkit-doesnt-update-the-state
- https://bezkoder.com/redux-toolkit-crud-react-hooks/
Conclusion
The duplicate appears because your form dispatches a create for every submit in addition to update; fix the component to only dispatch create when creating a new post, and make the slice update replace the matching entry (use findIndex + direct assignment, a map-based replace, or the createEntityAdapter helpers). Standardize id types and rely on adapter selectors to avoid this class of bugs in future.