Understanding createSlice
createSlice is a function that accepts an object of reducer functions, a slice name, and an initial state value, and automatically generates action creators and action types that correspond to the reducers and state.
🍰 The Layer Cake Analogy
Think of createSlice as making a layer cake:
- Slice Name: The cake's name label
- Initial State: The cake base
- Reducers: Different layers of the cake
- Actions: The frosting that connects everything
- Extra Reducers: Decorations from other cakes
Just as a layer cake combines multiple components into one dessert, createSlice combines actions, reducers, and selectors into one feature slice.
createSlice API in Detail
graph TD
A[createSlice] --> B[name]
A --> C[initialState]
A --> D[reducers]
A --> E[extraReducers]
D --> F[Regular Reducers]
D --> G[Prepared Reducers]
E --> H[Builder Callback]
E --> I[Object Notation]
A --> J[Generated Actions]
A --> K[Reducer Function]
style A fill:#f96
style J fill:#9f9
style K fill:#9f9
Basic createSlice Structure
import { createSlice } from '@reduxjs/toolkit';
const sliceName = createSlice({
name: 'featureName', // Used in action types
initialState: {}, // Initial state value
reducers: { // Reducer functions
actionName: (state, action) => {
// Can "mutate" state directly thanks to Immer
}
},
extraReducers: (builder) => { // Handle external actions
builder.addCase(externalAction, (state, action) => {
// Handle actions from other slices or createAsyncThunk
});
}
});
// What createSlice generates:
// 1. Action creators: sliceName.actions.actionName
// 2. Reducer function: sliceName.reducer
// 3. Action types: 'featureName/actionName'
Advanced Reducer Patterns
const todosSlice = createSlice({
name: 'todos',
initialState: {
items: [],
filter: 'all',
loading: false
},
reducers: {
// Simple reducer
setFilter: (state, action) => {
state.filter = action.payload;
},
// Reducer with prepare callback
addTodo: {
reducer: (state, action) => {
state.items.push(action.payload);
},
prepare: (text) => {
return {
payload: {
id: nanoid(),
text,
completed: false,
createdAt: new Date().toISOString()
}
};
}
},
// Reducer that returns a new state
resetTodos: () => {
// Return a new state instead of mutating
return {
items: [],
filter: 'all',
loading: false
};
},
// Reducer with complex logic
toggleTodo: (state, action) => {
const todo = state.items.find(item => item.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
// Update completion timestamp
todo.completedAt = todo.completed ? new Date().toISOString() : null;
}
},
// Reducer with validation
updateTodo: {
reducer: (state, action) => {
const { id, updates } = action.payload;
const todo = state.items.find(item => item.id === id);
if (todo) {
Object.assign(todo, updates);
}
},
prepare: (id, updates) => {
// Validate updates
if (!id) throw new Error('ID is required');
if (!updates || typeof updates !== 'object') {
throw new Error('Updates must be an object');
}
return { payload: { id, updates } };
}
}
}
});
// Using generated actions
dispatch(todosSlice.actions.setFilter('active'));
dispatch(todosSlice.actions.addTodo('Learn Redux Toolkit'));
dispatch(todosSlice.actions.toggleTodo(todoId));
Working with extraReducers
const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
status: 'idle',
error: null
},
reducers: {
postAdded: (state, action) => {
state.items.push(action.payload);
}
},
extraReducers: (builder) => {
builder
// Handle a specific action type
.addCase(fetchPosts.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
// Handle multiple actions with the same reducer
.addMatcher(
(action) => action.type.endsWith('/pending'),
(state) => {
state.status = 'loading';
}
)
// Handle all other actions
.addDefaultCase((state, action) => {
// Default logic for unhandled actions
});
}
});
// Alternative: Object notation for extraReducers
const alternativeSlice = createSlice({
name: 'alternative',
initialState,
reducers: {},
extraReducers: {
[fetchPosts.pending]: (state) => {
state.status = 'loading';
},
[fetchPosts.fulfilled]: (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
},
[fetchPosts.rejected]: (state, action) => {
state.status = 'failed';
state.error = action.error.message;
}
}
});
Understanding createAsyncThunk
sequenceDiagram
participant C as Component
participant A as AsyncThunk
participant R as Reducer
participant API as API/Server
C->>A: dispatch(asyncThunk())
A->>R: dispatch(pending)
R->>C: Update UI (loading)
A->>API: Make API call
API-->>A: Return response
alt Success
A->>R: dispatch(fulfilled)
R->>C: Update UI (success)
else Error
A->>R: dispatch(rejected)
R->>C: Update UI (error)
end
createAsyncThunk Basic Usage
import { createAsyncThunk } from '@reduxjs/toolkit';
// Basic async thunk
export const fetchUserById = createAsyncThunk(
'users/fetchById', // Action type prefix
async (userId, thunkAPI) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
);
// What createAsyncThunk generates:
// 1. fetchUserById.pending: 'users/fetchById/pending'
// 2. fetchUserById.fulfilled: 'users/fetchById/fulfilled'
// 3. fetchUserById.rejected: 'users/fetchById/rejected'
// Using in a component
const UserProfile = ({ userId }) => {
const dispatch = useDispatch();
const { user, status, error } = useSelector(state => state.users);
useEffect(() => {
dispatch(fetchUserById(userId));
}, [dispatch, userId]);
if (status === 'loading') return Loading...;
if (status === 'failed') return Error: {error};
if (status === 'succeeded') return Welcome, {user.name}!;
return null;
};
Advanced createAsyncThunk Patterns
// Async thunk with thunkAPI parameter
export const loginUser = createAsyncThunk(
'auth/login',
async (credentials, thunkAPI) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (!response.ok) {
const error = await response.json();
// Use rejectWithValue for custom error payload
return thunkAPI.rejectWithValue(error.message);
}
const data = await response.json();
// Access other parts of state
const state = thunkAPI.getState();
if (state.settings.rememberMe) {
localStorage.setItem('token', data.token);
}
// Dispatch additional actions
thunkAPI.dispatch(fetchUserProfile());
return data;
} catch (err) {
// Network or other errors
return thunkAPI.rejectWithValue('Network error occurred');
}
}
);
// Async thunk with condition
export const fetchNotifications = createAsyncThunk(
'notifications/fetch',
async (_, { getState }) => {
const response = await fetch('/api/notifications');
return response.json();
},
{
condition: (_, { getState }) => {
const { notifications } = getState();
// Don't fetch if already loading
if (notifications.status === 'loading') {
return false;
}
// Don't fetch if data is fresh (less than 1 minute old)
const lastFetched = notifications.lastFetched;
if (lastFetched && Date.now() - lastFetched < 60000) {
return false;
}
return true;
},
// Optional: dispatch action even if condition is false
dispatchConditionRejection: true
}
);
// Async thunk with progress tracking
export const uploadFile = createAsyncThunk(
'files/upload',
async (file, { dispatch }) => {
const formData = new FormData();
formData.append('file', file);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
dispatch(updateUploadProgress(progress));
}
};
xhr.onload = () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error('Upload failed'));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.open('POST', '/api/files/upload');
xhr.send(formData);
});
}
);
// Async thunk with cancellation
export const searchProducts = createAsyncThunk(
'products/search',
async (query, { signal }) => {
const response = await fetch(`/api/products/search?q=${query}`, {
signal // Pass AbortController signal
});
return response.json();
}
);
// Usage with cancellation
const [searchRequest, setSearchRequest] = useState();
const handleSearch = (query) => {
// Cancel previous search
if (searchRequest) {
searchRequest.abort();
}
// Start new search
const request = dispatch(searchProducts(query));
setSearchRequest(request);
};
Integrating createSlice with createAsyncThunk
// Complete feature example - Posts with async operations
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit';
import { client } from '../../api/client';
// Async thunks
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/api/posts');
return response.data;
});
export const addNewPost = createAsyncThunk(
'posts/addNewPost',
async (initialPost, { getState, rejectWithValue }) => {
try {
// Add user info from state
const { auth: { user } } = getState();
const post = {
...initialPost,
author: user.id,
date: new Date().toISOString()
};
const response = await client.post('/api/posts', post);
return response.data;
} catch (err) {
return rejectWithValue(err.response.data);
}
}
);
export const updatePost = createAsyncThunk(
'posts/updatePost',
async ({ id, ...updates }, { rejectWithValue }) => {
try {
const response = await client.patch(`/api/posts/${id}`, updates);
return response.data;
} catch (err) {
return rejectWithValue(err.response.data);
}
}
);
// Slice
const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
currentRequestId: undefined // For handling race conditions
},
reducers: {
postUpdated: (state, action) => {
const { id, changes } = action.payload;
const existingPost = state.items.find(post => post.id === id);
if (existingPost) {
Object.assign(existingPost, changes);
}
},
reactionAdded: (state, action) => {
const { postId, reaction } = action.payload;
const existingPost = state.items.find(post => post.id === postId);
if (existingPost) {
existingPost.reactions[reaction]++;
}
}
},
extraReducers: (builder) => {
builder
// Fetch posts
.addCase(fetchPosts.pending, (state, action) => {
if (state.status === 'idle') {
state.status = 'loading';
state.currentRequestId = action.meta.requestId;
}
})
.addCase(fetchPosts.fulfilled, (state, action) => {
if (
state.status === 'loading' &&
state.currentRequestId === action.meta.requestId
) {
state.status = 'succeeded';
state.items = action.payload;
state.currentRequestId = undefined;
}
})
.addCase(fetchPosts.rejected, (state, action) => {
if (
state.status === 'loading' &&
state.currentRequestId === action.meta.requestId
) {
state.status = 'failed';
state.error = action.error.message;
state.currentRequestId = undefined;
}
})
// Add new post
.addCase(addNewPost.fulfilled, (state, action) => {
state.items.push(action.payload);
})
// Update post
.addCase(updatePost.fulfilled, (state, action) => {
const { id } = action.payload;
const existingPost = state.items.find(post => post.id === id);
if (existingPost) {
Object.assign(existingPost, action.payload);
}
})
// Handle all rejected cases
.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state, action) => {
state.error = action.payload || action.error.message;
}
);
}
});
export const { postUpdated, reactionAdded } = postsSlice.actions;
export default postsSlice.reducer;
// Selectors
export const selectAllPosts = state => state.posts.items;
export const selectPostById = (state, postId) =>
state.posts.items.find(post => post.id === postId);
export const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => userId],
(posts, userId) => posts.filter(post => post.author === userId)
);
// Memoized selector with multiple inputs
export const selectFilteredPosts = createSelector(
[
selectAllPosts,
state => state.posts.searchTerm,
state => state.posts.filter
],
(posts, searchTerm, filter) => {
let filteredPosts = posts;
if (searchTerm) {
filteredPosts = filteredPosts.filter(post =>
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.content.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (filter !== 'all') {
filteredPosts = filteredPosts.filter(post => post.category === filter);
}
return filteredPosts;
}
);
Real-World Example: E-commerce Cart
// features/cart/cartSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { client } from '../../api/client';
// Async thunks
export const fetchCart = createAsyncThunk(
'cart/fetchCart',
async (_, { getState }) => {
const token = getState().auth.token;
const response = await client.get('/api/cart', {
headers: { Authorization: `Bearer ${token}` }
});
return response.data;
}
);
export const addToCart = createAsyncThunk(
'cart/addToCart',
async ({ productId, quantity = 1 }, { getState, rejectWithValue }) => {
try {
const token = getState().auth.token;
const response = await client.post(
'/api/cart/items',
{ productId, quantity },
{ headers: { Authorization: `Bearer ${token}` } }
);
return response.data;
} catch (err) {
return rejectWithValue(err.response.data);
}
}
);
export const updateCartItem = createAsyncThunk(
'cart/updateItem',
async ({ itemId, quantity }, { getState, rejectWithValue }) => {
try {
const token = getState().auth.token;
const response = await client.patch(
`/api/cart/items/${itemId}`,
{ quantity },
{ headers: { Authorization: `Bearer ${token}` } }
);
return response.data;
} catch (err) {
return rejectWithValue(err.response.data);
}
}
);
export const removeFromCart = createAsyncThunk(
'cart/removeItem',
async (itemId, { getState }) => {
const token = getState().auth.token;
await client.delete(`/api/cart/items/${itemId}`, {
headers: { Authorization: `Bearer ${token}` }
});
return itemId;
}
);
export const checkout = createAsyncThunk(
'cart/checkout',
async (paymentDetails, { getState, dispatch, rejectWithValue }) => {
try {
const token = getState().auth.token;
const response = await client.post(
'/api/checkout',
paymentDetails,
{ headers: { Authorization: `Bearer ${token}` } }
);
// Clear cart after successful checkout
dispatch(clearCart());
return response.data;
} catch (err) {
return rejectWithValue(err.response.data);
}
}
);
// Cart slice
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
status: 'idle',
error: null,
total: 0,
itemCount: 0,
lastUpdated: null
},
reducers: {
clearCart: (state) => {
state.items = [];
state.total = 0;
state.itemCount = 0;
state.lastUpdated = new Date().toISOString();
},
updateItemQuantity: (state, action) => {
const { itemId, quantity } = action.payload;
const item = state.items.find(item => item.id === itemId);
if (item && quantity > 0) {
item.quantity = quantity;
cartSlice.caseReducers.calculateTotals(state);
}
},
calculateTotals: (state) => {
state.total = state.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
state.itemCount = state.items.reduce((sum, item) =>
sum + item.quantity, 0
);
}
},
extraReducers: (builder) => {
builder
// Fetch cart
.addCase(fetchCart.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchCart.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload.items;
state.total = action.payload.total;
state.itemCount = action.payload.itemCount;
state.lastUpdated = new Date().toISOString();
})
.addCase(fetchCart.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
// Add to cart
.addCase(addToCart.pending, (state) => {
state.status = 'loading';
})
.addCase(addToCart.fulfilled, (state, action) => {
state.status = 'succeeded';
const existingItem = state.items.find(
item => item.productId === action.payload.productId
);
if (existingItem) {
existingItem.quantity += action.payload.quantity;
} else {
state.items.push(action.payload);
}
cartSlice.caseReducers.calculateTotals(state);
state.lastUpdated = new Date().toISOString();
})
.addCase(addToCart.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload || action.error.message;
})
// Update cart item
.addCase(updateCartItem.fulfilled, (state, action) => {
const item = state.items.find(item => item.id === action.payload.id);
if (item) {
item.quantity = action.payload.quantity;
cartSlice.caseReducers.calculateTotals(state);
state.lastUpdated = new Date().toISOString();
}
})
// Remove from cart
.addCase(removeFromCart.fulfilled, (state, action) => {
state.items = state.items.filter(item => item.id !== action.payload);
cartSlice.caseReducers.calculateTotals(state);
state.lastUpdated = new Date().toISOString();
})
// Checkout
.addCase(checkout.pending, (state) => {
state.status = 'loading';
})
.addCase(checkout.fulfilled, (state) => {
state.status = 'succeeded';
// Cart will be cleared by the dispatch in the thunk
})
.addCase(checkout.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload || action.error.message;
});
}
});
export const { clearCart, updateItemQuantity } = cartSlice.actions;
export default cartSlice.reducer;
// Selectors
export const selectCartItems = state => state.cart.items;
export const selectCartTotal = state => state.cart.total;
export const selectCartItemCount = state => state.cart.itemCount;
export const selectCartStatus = state => state.cart.status;
export const selectCartItemById = (state, itemId) =>
state.cart.items.find(item => item.id === itemId);
// Component using the cart slice
const ShoppingCart = () => {
const dispatch = useDispatch();
const cartItems = useSelector(selectCartItems);
const cartTotal = useSelector(selectCartTotal);
const cartStatus = useSelector(selectCartStatus);
useEffect(() => {
if (cartStatus === 'idle') {
dispatch(fetchCart());
}
}, [cartStatus, dispatch]);
const handleRemoveItem = (itemId) => {
dispatch(removeFromCart(itemId));
};
const handleUpdateQuantity = (itemId, quantity) => {
dispatch(updateCartItem({ itemId, quantity }));
};
const handleCheckout = async () => {
try {
const result = await dispatch(checkout(paymentDetails)).unwrap();
// Navigate to confirmation page
navigate(`/order/${result.orderId}`);
} catch (err) {
// Handle error
toast.error(err.message);
}
};
if (cartStatus === 'loading') return <LoadingSpinner />;
if (cartStatus === 'failed') return <ErrorMessage />;
return (
<div className="shopping-cart">
{cartItems.map(item => (
<CartItem
key={item.id}
item={item}
onRemove={handleRemoveItem}
onUpdateQuantity={handleUpdateQuantity}
/>
))}
<div className="cart-total">
Total: ${cartTotal.toFixed(2)}
</div>
<button onClick={handleCheckout}>
Checkout
</button>
</div>
);
};
Advanced Patterns and Tips
1. Handling Loading States
// Generic loading state handler
const withLoadingState = (builder, asyncThunk, sliceName) => {
builder
.addCase(asyncThunk.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(asyncThunk.fulfilled, (state) => {
state.status = 'succeeded';
})
.addCase(asyncThunk.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload || action.error.message;
});
};
// Usage in a slice
const userSlice = createSlice({
name: 'user',
initialState: {
data: null,
status: 'idle',
error: null
},
reducers: {},
extraReducers: (builder) => {
withLoadingState(builder, fetchUser);
builder.addCase(fetchUser.fulfilled, (state, action) => {
state.data = action.payload;
});
}
});
2. Optimistic Updates
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [] },
reducers: {
todoToggled: (state, action) => {
const todo = state.items.find(t => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
}
}
});
// Optimistic update thunk
export const toggleTodoOptimistic = (todoId) => async (dispatch, getState) => {
// Optimistically update UI
dispatch(todoToggled(todoId));
try {
// Make API call
await api.toggleTodo(todoId);
} catch (error) {
// Revert on error
dispatch(todoToggled(todoId));
throw error;
}
};
3. Request Deduplication
// Prevent duplicate requests
const pendingRequests = new Map();
export const fetchUserOnce = createAsyncThunk(
'users/fetchOnce',
async (userId, { rejectWithValue }) => {
// Check if request is already pending
if (pendingRequests.has(userId)) {
return pendingRequests.get(userId);
}
// Create new request
const request = api.fetchUser(userId)
.then(response => {
pendingRequests.delete(userId);
return response;
})
.catch(error => {
pendingRequests.delete(userId);
throw error;
});
pendingRequests.set(userId, request);
return request;
}
);
4. Slice Factories
// Create reusable slice factory
const createCrudSlice = (name, apiEndpoint) => {
const fetchItems = createAsyncThunk(
`${name}/fetchItems`,
async () => {
const response = await api.get(apiEndpoint);
return response.data;
}
);
const addItem = createAsyncThunk(
`${name}/addItem`,
async (item) => {
const response = await api.post(apiEndpoint, item);
return response.data;
}
);
return createSlice({
name,
initialState: {
items: [],
status: 'idle',
error: null
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchItems.fulfilled, (state, action) => {
state.items = action.payload;
})
.addCase(addItem.fulfilled, (state, action) => {
state.items.push(action.payload);
});
}
});
};
// Usage
const productsSlice = createCrudSlice('products', '/api/products');
const categoriesSlice = createCrudSlice('categories', '/api/categories');
Testing Slices and Async Thunks
// Testing a slice
import reducer, { increment, decrement } from './counterSlice';
describe('counter reducer', () => {
it('should handle initial state', () => {
expect(reducer(undefined, { type: 'unknown' })).toEqual({
value: 0,
status: 'idle'
});
});
it('should handle increment', () => {
const actual = reducer({ value: 3 }, increment());
expect(actual.value).toEqual(4);
});
it('should handle decrement', () => {
const actual = reducer({ value: 3 }, decrement());
expect(actual.value).toEqual(2);
});
});
// Testing async thunks
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fetchPosts } from './postsSlice';
import api from '../../api/client';
jest.mock('../../api/client');
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
describe('posts async actions', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('creates FETCH_POSTS_FULFILLED when fetching posts succeeds', async () => {
const mockPosts = [{ id: 1, title: 'Test Post' }];
api.get.mockResolvedValueOnce({ data: mockPosts });
const expectedActions = [
{ type: fetchPosts.pending.type },
{ type: fetchPosts.fulfilled.type, payload: mockPosts }
];
const store = mockStore({ posts: { items: [] } });
await store.dispatch(fetchPosts());
const actions = store.getActions();
expect(actions[0].type).toEqual(expectedActions[0].type);
expect(actions[1].type).toEqual(expectedActions[1].type);
expect(actions[1].payload).toEqual(expectedActions[1].payload);
});
it('creates FETCH_POSTS_REJECTED when fetching posts fails', async () => {
const errorMessage = 'Network Error';
api.get.mockRejectedValueOnce(new Error(errorMessage));
const store = mockStore({ posts: { items: [] } });
await store.dispatch(fetchPosts());
const actions = store.getActions();
expect(actions[0].type).toEqual(fetchPosts.pending.type);
expect(actions[1].type).toEqual(fetchPosts.rejected.type);
expect(actions[1].error.message).toEqual(errorMessage);
});
});
Best Practices
Do's
- Keep slices focused on a single feature
- Use extraReducers for async actions and cross-slice actions
- Leverage prepare callbacks for consistent action payloads
- Write selectors in the slice file
- Handle all async states (pending, fulfilled, rejected)
- Use TypeScript for better type safety
Don'ts
- Don't put business logic in components
- Don't access state directly - use selectors
- Don't mutate state outside of createSlice
- Don't dispatch actions in reducers
- Don't make API calls directly in reducers
Performance Tips
- Use createSelector for expensive computations
- Normalize state shape to avoid deep updates
- Consider using entity adapters for collections
- Use the unwrap() method for better error handling
Practice Exercise
Task: Create a Comment System
Build a comment system slice with the following features:
- Fetch comments for a post
- Add new comments
- Edit existing comments
- Delete comments
- Like/dislike comments
- Handle nested replies
// TODO: Create comments slice with async thunks
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// TODO: Define async thunks
export const fetchComments = createAsyncThunk(/* ... */);
export const addComment = createAsyncThunk(/* ... */);
export const updateComment = createAsyncThunk(/* ... */);
export const deleteComment = createAsyncThunk(/* ... */);
export const toggleCommentLike = createAsyncThunk(/* ... */);
// TODO: Create the slice
const commentsSlice = createSlice({
name: 'comments',
initialState: {
items: [],
status: 'idle',
error: null
},
reducers: {
// Add any synchronous actions here
},
extraReducers: (builder) => {
// Handle async actions
}
});
// TODO: Export actions and selectors
export const selectCommentsByPost = (state, postId) => /* ... */;
export const selectCommentReplies = (state, parentId) => /* ... */;
// TODO: Create components that use this slice
const CommentList = ({ postId }) => {
// Implement component
};
const CommentForm = ({ postId, parentId }) => {
// Implement component
};