Updating deeply nested state in Redux - reactjs

I'm updating a "message board" project I did (Brad Traversy's MERN stack course) to include comment likes. For a simple example, think Facebook: you create a post which people can like, and people can respond with comments, which can also be liked.
I have a "post" piece of state, which contains post likes (post.likes array), comments (post.comments array), and comment likes (where things get tricky: an array nested inside the post.comments array).
How do I update the nested arrays in my reducer so my page re-renders properly? Right now, it records the action and shows the new likes/dislikes when the page reloads manually, but it won't reload the page itself.
I've tried updating the state, but the reality is, I'm not entirely sure how to loop through and update something deeply nested.
Here is my actual post state, courtesy of Redux DevTools.
post: {
posts: [],
post: {
_id: '5cebd8bcdc17fd5cd7e57a45',
text: 'This is my brand new post.',
name: 'Bobby Smith',
avatar: '//www.gravatar.com/avatar/414868368393e3ba8ae5ff93eeb98de6?s=200&r=pg&d=mm',
user: '5cd646c9a632b51373121995',
likes: [
{
_id: '5cebd8d1dc17fd5cd7e57a47',
user: '5cd36ce5fda120050ee64160'
}
],
comments: [
{
date: '2019-05-27T12:32:16.172Z',
likes: [ /*-------- This ---------*/
{
_id: '5cebd8e1dc17fd5cd7e57a48',
user: '5cd646c9a632b51373121995'
}
],
_id: '5cebd8d0dc17fd5cd7e57a46',
text: 'And this is my brand new response.',
name: 'John Doe',
avatar: '//www.gravatar.com/avatar/b2b146dba9e0023cb56637f0df4aa005?s=200&r=pg&d=mm',
user: '5cd36ce5fda120050ee64160'
}
],
date: '2019-05-27T12:31:56.598Z',
__v: 3
},
loading: false,
error: {}
}
}
Reducer:
const initialState = {
posts: [],
post: null,
loading: true,
error: {}
}
export default function(state = initialState, action) {
const { type, payload } = action
switch (type) {
case UPDATE_COMMENT_LIKES:
return {
...state,
post: { ...state.post, comments: ???? }
}
default:
return state
}
}
It's passing in the post ID and the user ID, and then filtering based on whether or not they already exist. I'll also add the action creators, just for clarity.
// Add like to comment
export const addCommentLike = id => async dispatch => {
try {
const res = await axios.put(`/api/posts/comment/like/${id}`)
dispatch({
type: UPDATE_COMMENT_LIKES,
payload: { id, likes: res.data }
})
} catch (err) {
dispatch({
type: POST_ERROR,
payload: { msg: err.response.statusText, status: err.response.status }
})
}
}
// Remove like from comment
export const removeCommentLike = id => async dispatch => {
try {
const res = await axios.put(`/api/posts/comment/unlike/${id}`)
dispatch({
type: UPDATE_COMMENT_LIKES,
payload: { id, likes: res.data }
})
} catch (err) {
dispatch({
type: POST_ERROR,
payload: { msg: err.response.statusText, status: err.response.status },
loading: false
})
}
}
Right now, it's updating everything in the database, but it's not updating the state immediately and triggering a re-render.
I'd appreciate any help I can get.
Thanks!

I know it's a bit too late but it might help for someone. I also did this course btw ;)
So in your case reducer would look like this:
case UPDATE_COMMENT_LIKES:
return {
...state,
post: {
...state.post,
comments: state.post.comments.map((comment) =>
comment._id === payload.id
? { ...comment, likes: payload.likes }
: comment
)
},
loading: false
};

It would be better if you'll split the remove/add logic in your reducer as well:
ADD_COMMENT_LIKES - will add the like to the nested likes array
REMOVE_COMMENT_LIKES - will filter the chosen like by its id
export default function(state = initialState, action) {
const { type, payload } = action;
switch (type) {
case ADD_COMMENT_LIKES:
return {
...state,
post: {
...state.post,
comments: [
...state.post.comments,
likes: [
...state.post.comments.likes,
payload <--- inserting the new like
]
]
}
}
case REMOVE_COMMENT_LIKES:
return {
...state,
post: {
...state.post,
comments: [
...state.post.comments,
likes: [
...state.post.comments.likes.filter((item) => item.id !== payload.id)
]
]
}
default:
return state
}
}
Read how to combine reducers, and form your state as a single independent branches. It will help you structure your reducer in a more maintainable structure.

Related

Concat arrays in redux reducer

I'm using an Unsplash API, and trying to make pagination. I don't want to replace one page with another, but to call more images results and concate it to an existing ones. This is my action:
export const getUnsplashImages = (page = 1) => async (dispatch) => {
dispatch({ type: GET_UNSPLASH_LOADING });
try {
const { data } = await unsplashUrl.get(
`${Constants.UNSPLASH_PHOTOS}?page=${page}&${Constants.UNSPLASH_CLIENT_ID}&per_page=13`
);
dispatch({ type: GET_UNSPLASH, payload: data });
} catch (error) {
dispatch({ type: GET_UNSPLASH_ERROR });
}
};
And I'm trying to do the concatenation in reducer:
const initState = {
images: [],
loading: false,
error: '',
};
export default function (state = initState, action) {
switch (action.type) {
...
case GET_UNSPLASH:
console.log('STATE', state);
return {
...state,
loading: false,
images: [...state.images, action.payload],
};
...
default:
return state;
}
}
But I'm getting an array inside an array. I tried to do it like this images: [...state.images, ...action.payload], But result is not as I expected. I'm getting a result of 26 images, instead of concatenating the results
Assuming payload: data is an array that you want to concat within images, you need to spread it:
images: [...state.images, ...action.payload]

Same redux actions dispatched in quick succession

I have an application with like button. User can like multiple posts in quick succession. I send the action to update likecount and add like/user record through a redux action.
export const likePost = (payload) => (dispatch) => {
dispatch({
type: "LIKE_POST",
payload,
});
};
In the saga on successful update, the control of action comes in both cases but LIKE_POST_SUCCESSFUL is triggered only for the last.
function* requestLikePost(action) {
const { postId } = action.payload;
try {
const response = yield call(callLikePostsApi, postId);
yield put({
type: "LIKE_POST_SUCCESSFUL",
payload: response.data,
});
} catch (error) {
yield put({
type: "LIKE_POST_FAILED",
payload: error.response.data,
});
}
}
These are recieving action in reducer. The LIKE_POST is triggered two times as expected but not the LIKE_POST_SUCCESSFUL, its triggered only for the last though both reached .
case "LIKE_POST":
return {
...state,
errors: {},
};
case "LIKE_POST_SUCCESSFUL":
updatedPosts = state.posts.map((post) => {
if (post.postId === action.payload.postId) {
return action.payload;
}
return post;
});
updatedLikes = [
...state.likes,
{ userName: state.profile.userName, postId: action.payload.postId },
];
console.log("updatedLikes", updatedLikes, action.payload);
return {
...state,
posts: updatedPosts,
likes: updatedLikes,
loading: false,
};
API call
const callLikePostsApi = (postId) => axios.get(`/post/${postId}/like`);
Are you using takeLatest() effect for your saga function requestLikePost? It will take only latest action call into consideration and aborts all the previous calls if it happens in quick succession.
Use takeEvery() saga effect instead.

How do I avoid using separate _PENDING _FULFILLED and _REJECTED actions with redux thunk?

I am writing my actions and reducers with thunks that dispatch _PENDING, _FULFILLED, and _REJECTED actions. However, I am wanting a better solution to avoid the boilerplate. I am migrating to Typescript which doubles this boilerplate by requiring an interface for each _PENDING, _FULFILLED, and _REJECTED action. It is just getting out of hand. Is there a way to get the same/similar functionality of my code without having three action types per thunk?
localUserReducer.js
const initialState = {
fetching: false,
fetched: false,
user: undefined,
errors: undefined,
};
export default function (state = initialState, action) {
switch (action.type) {
case 'GET_USER_PENDING':
return {
...state,
fetching: true,
};
case 'GET_USER_FULFILLED':
return {
...state,
fetching: false,
fetched: true,
user: action.payload,
};
case 'GET_USER_REJECTED':
return {
...state,
fetching: false,
errors: action.payload,
};
default:
return state;
}
}
localUserActions.js
import axios from 'axios';
export const getUser = () => async (dispatch) => {
dispatch({ type: 'GET_USER_PENDING' });
try {
const { data } = await axios.get('/api/auth/local/current');
dispatch({ type: 'GET_USER_FULFILLED', payload: data });
} catch (err) {
dispatch({ type: 'GET_USER_REJECTED', payload: err.response.data });
}
};
I may have a huge misunderstand of redux-thunk as I am a newbie. I don't understand how I can send _REJECTED actions if I use the implementation of Typescript and redux-thunk documented here: https://redux.js.org/recipes/usage-with-typescript#usage-with-redux-thunk
There is a way to get the similar functionality without having three action types per thunk, but it will have some impact on the rendering logic.
I'd recommend pushing the transient aspect of the async calls down to the data. So rather than marking your actions as _PENDING, _FULFILLED, and _REJECTED, mark your data that way, and have a single action.
localUser.js (new file for the user type)
// Use a discriminated union here to keep inapplicable states isolated
type User =
{ status: 'ABSENT' } |
{ status: 'PENDING' } |
{ status: 'FULLFILLED', data: { fullName: string } } |
{ status: 'REJECTED', error: string };
// a couple of constructors for the fullfilled and rejected data
function dataFulFilled(data: { fullName: string }) {
return ({ status: 'FULLFILLED', data });
}
function dataRejected(error: string) {
return ({ status: 'REJECTED', error });
}
localUserReducer.js
const initialState: { user: User } = { user: { status: 'ABSENT' } };
export default function (state = initialState, action): { user: User } {
switch (action.type) {
case 'USER_CHANGED':
return {
...state,
user: action.payload
};
default:
return state;
}
}
localUserActions.js
import axios from 'axios';
export const getUser = () => async (dispatch) => {
dispatch({ type: 'USER_CHANGED', payload: { status: 'PENDING' } });
try {
const { data } = await axios.get('/api/auth/local/current');
dispatch({ type: 'USER_CHANGED', payload: dataFulFilled(data) });
} catch (err) {
dispatch({ type: 'USER_CHANGED', payload: dataRejected(err.response.data) });
}
};
This will also remove the need for the multiple boolean fields (fetching and fetched) and isolate the various data states from accidental modification.
The changes to the render logic will be necessary, but will likely be an improvement. Rather than combinations of nested if-else statements using the booleans, a single switch can be used to handle the four cases of the data state.
Then you can invoke something like this from your render function...
function userElement(user: User) {
switch (user.status) {
case 'ABSENT':
return <></>;
case 'PENDING':
return <div>Fetching user information...Please be patient...</div>;
case 'FULLFILLED':
return <div>{user.data.fullName}</div>;
case 'REJECTED':
return <h1>The error is: {user.error}</h1>
}
}
I hope that helps. Good luck!

Reducer not updating props in component correctly

My comments are dissappearing from my component after didMount() initializes them? It's really strange!
React component:
componentDidMount = (post) => {
const postId = this.props.post.id
console.log('postpreview', postId)
this.props.fetchComments(postId)
console.log('postpreview comments:', this.props.comments)
}
Redux Actions:
export const beginFetchComments = () => ({
type: C.BEGIN_FETCH_COMMENTS,
})
export const fetchCommentsFailed = (error) => ({
type: C.FETCH_COMMENTS_FAILED,
payload: { error },
})
export const fetchCommentsSuccess = (comments) => ({
type: C.FETCH_COMMENTS_SUCCESS,
payload: { comments }
})
export function fetchComments(postId) {
return dispatch => {
dispatch(beginFetchComments());
return fetch(`${api}/posts/${postId}/comments`, { headers })
.then(
res => res.json(),
error => console.log('An error occurred at fetchComments', error)
)
.then(json => {
dispatch(fetchCommentsSuccess(json));
return json;
});
};
}
Redux Reducer (switch case):
case C.BEGIN_FETCH_COMMENTS:
return {
...state,
loading: true,
error: null
};
case C.FETCH_COMMENTS_SUCCESS:
console.log(action.payload.comments);
const comments = _.mapKeys(action.payload.comments)
return {
...state,
loading: false,
comments,
};
The console displays this for the same console.log(), (I can't get my hands on my props!):
(2) [{…}, {…}]0: {id: "894tuq4ut84ut8v4t8wun89g", parentId: "8xf0y6ziyjabvozdd253nd", timestamp: 1468166872634, body: "Hi there! I am a COMMENT.", author: "thingtwo", …}1: {id: "8tu4bsun805n8un48ve89", parentId: "8xf0y6ziyjabvozdd253nd", timestamp: 1469479767190, body: "Comments. Are. Cool.", author: "thingone", …}length: 2__proto__: Array(0)
commentsReducer.js:22 []
I don't know what is the use of mapKeys here but what I would do is do a console.log to see if I'm getting an object and under what key there is a comments array:
case C.FETCH_COMMENTS_SUCCESS:
console.log(action.payload.comments); // is this logging an array?
return {
...state,
loading: false,
comments: action.payload.comments,
};
The bottom code I posted is the console.log - the object appears populated and then rerenders empty

Redux - why loading everything in state at root

I am trying to understand Redux and having some difficulty.
I understand the concept of combineReducer, ie ....
var reducer = combineReducers({
user: userReducer,
products: productsReducer
})
But what if I have thousands of products, only available on the products page. I do not understand why I need to load them at root; to me this will slow the initial start up of the app for something that will not be needed unless the user goes to the products page.
Is this just the way it is with redux?
In Redux apps, you always build your entire state at the start. With Redux you have one store and one state - everything should trickle down from that one state to props on your components. However, that does not mean you actually need to load all the data into the state at launch, only that the structure needs to be there. This is why you should set up an initial state object for each reducer.
Let's say you have thousands of product records that you load from the database. In your products reducer you could do something like this:
const initialState = {
data: []
};
//use ES6 default parameters
function productsReducer (state = initialState, action) {
switch (action.type) {
case 'GET_PRODUCTS':
//return data from action
return {
data: action.result
};
default:
return state;
}
}
This means that when you start your app, if you use the full reducer you declared in your post, your application state will look like this:
{
user: {},
products: {
data: []
}
}
products.data will be an empty array until you fire an action that actually requires you to load the products data (i.e. you go to the Products page in your app or something). It's true that the products data will remain in your state if you then go elsewhere in your app, but this is a great thing - the next time you render the Products page you will already have the data at your disposal without having to do a database lookup.
In our app, we made an API for the products and it has limit of 15 per page. So our reducer goes like this.
collection: {
"total": 0,
"per_page": 0,
"current_page": 0,
"last_page": 0,
"from": 0,
"to": 0,
data: []
},
isFetching: false,
isFetchingError: false
on the first load we fetched limited amount of products, then we made a pagination out of it.. using selectors in redux https://github.com/rackt/reselect
Loading a thousands of data will get your app very slow.
const paginated = (state = initialState, action) => {
switch (action.type) {
case FETCH_PAGINATED_PRODUCTS:
return {
...state,
isFetching: true,
isFetchingError: false
};
case FETCH_PAGINATED_PRODUCTS_SUCCESS:
return {
...state,
collection: action.payload,
isFetching: false
};
case FETCH_PAGINATED_PRODUCTS_ERROR:
return {
...state,
isFetching: false,
isFetchingError: true
};
default:
return state
we have used axios for request:
https://github.com/mzabriskie/axios
Here's how we implement axios in redux-async
export function getAll(page = 1) {
return (dispatch, getState) => {
const state = getState();
const { filters } = state.products.paginated;
if ( state.products.paginated.isFetching ) {
return;
}
dispatch({ type: FETCH_PAGINATED_PRODUCTS });
return axios
.get(`products?page=${page}&limit=16&filters=${JSON.stringify(filters)}`)
.then((res) => dispatch({
type: FETCH_PAGINATED_PRODUCTS_SUCCESS,
payload: res.data
}))
.catch((res) => dispatch({
type: FETCH_PAGINATED_PRODUCTS_ERROR,
/*payload: res.data.error,*/
error: true
}));
}
}
export function get(id) {
return (dispatch, getState) => {
const state = getState();
if ( state.products.resource.isFetching ) {
return;
}
dispatch({ type: FETCH_PRODUCT });
return axios
.get(`products/${id}`)
.then((res) => dispatch({
type: FETCH_PRODUCT_SUCCESS,
payload: res.data.data
}))
.catch((res) => dispatch({
type: FETCH_PRODUCT_ERROR,
/*payload: new Error(res.data.error),*/
error: true
}));
}

Resources