I am currently starting with Redux and it is unclear to me what is the proper way of binding reducer to sub, dynamically set, parts of the state.
For instance let's say my state looks like this (after asynchronously fetching some data from backend APIs)
{
"categories": {
"collection": {
"42": {
"id": "42",
"title": "whatever",
"owner_id": "10"
"posts": {
"collection": {
"36": {
"id": "36",
"title": "hello",
"content": "hello world"
},
"37": { // more posts ... }
},
"ids": ["36", "37", ...]
},
"otherChildren": { // more sub-entities }
},
"43": { // more categories ... }
},
"ids": ["42", "43", ...]
},
"users": {
"collection": {
"10": {
"id": "10"
"email": "what#ever.com"
},
"11": { // more users ... }
},
"ids": [10, 11]
}
}
My root reducer would look like this :
export default combineReducers({
categories: categoriesReducer,
users: usersReducer
})
and the categoriesReducer :
function categoriesReducer(state = initialState, action) {
switch (action.type) {
case FETCH_ALL_CATEGORIES_SUCCESS:
return Object.assign({}, state, {
categories: {
collection: action.payload
}
})
default:
return state
}
}
Now what I'd like to do is to seamlessly delegate handle the post subset part of the state with postsReducer function, basically adding a case like :
case FETCH_CATEGORY_ALL_POSTS_SUCCESS:
let categoryId = action.categoryId
return Object.assign({}, state, {
categories: {
[categoryId]: combineReducers({
"posts": postsReducer,
"otherChildren": otherChildrenReducer
})
}
}
Of course, this isn't working. What I don't get is how to get redux to automatically update a subset of the state using combineReducer for nested reducers, while automatically passing the proper subset as state argument to the reducer function, and without overriding the existing data (i.e. the category in my example).
I somehow managed to make that work writing my own "delegate" fonction, but it feels pretty wrong - especially looking at https://github.com/reactjs/redux/blob/master/src/combineReducers.js which looks like doing exactly that.
How am I, conventionally, suppose to do that? Is that even possible to use combineReducers that way with Redux, am I misunderstanding the point of combineReducer or am I expecting to much magic from it ?
Thanks !
EDIT/UPDATE:
I do really need those to be nested (right, maybe the category/post example isn't the right one) and I'd like the reducer (i.e. here, postsReducer, but it could be a Collection reducer) to re-usable in multiple places.
(Why do I want it to be nested ? Actually in that example, let's say that one post can only belong to one category, since data of post are actually encrypted with a private key from category. That why it makes so much sens to me to represent this chain, this relation in the state)
Isn't there a way with redux to delegate to other reducer while passing the proper subset of the state - that is, for instance, passing state categories.collection.42.posts.collection.36 to a postReducer ?
You where doing it so nice with the store, why not keep on separating all the diferent collections.
I mean your posts should/could have another reducer, the store would en up like this:
{
categories: [],
posts: [],
users: []
}
Your categories would then contain only the ID's of the posts
So you could "capture" the "FETCH_ALL_CATEGORIES_SUCCESS" action in both reducers (categories and the new posts reducer), on categoryReducer you safe the category data, and the ID's, and the postsReducer will just save the posts
You are using combineReducers in the wrong way in the last code snippet. (It is correct in the first snippet where you're using it to combine categoriesReducer and usersReducer together.)
In the case of FETCH_CATEGORY_ALL_POSTS_SUCCESS, instead of calling combineReducers, just call a plain function inside of categoriesReducer.
posts: posts(action.payload.posts)
That plain function is basically a sub-reducer. It would be sth you write yourself, and you'd probably put it in the same file.
But two other main issues with your code above:
1) As another user has already said above, it probably wouldn't be best to store posts as a sub-property of categories. Rather store the posts as their own item within the state tree, and just have a category_id field for each post to show which category it belongs to. So your state tree in that case would look like:
{
categories: {},
posts: {},
users: {}
}
and you would use combineReducers initially as:
export default combineReducers({
categories: categoriesReducer,
posts: postsReducer,
users: usersReducer
})
2) Within the categoriesReducer (or any for that matter), you don't have to have the categories property, because you've already created that property when calling combineReducers. In other words in categoriesReducer you ought to just have it as this:
function categoriesReducer(state = initialState, action) {
switch (action.type) {
case FETCH_ALL_CATEGORIES_SUCCESS:
return Object.assign({}, state, {
collection: action.payload,
ids: // function to extract ids goes here
});
default:
return state;
}
}
Related
Let's say you are given an array of objects in your React state, like this:
[
{
"id": "…",
"title": "Brief history of Space Shuttle program",
"date": "2016-10-29 19:00:00+01:00",
"venue": "NASA History Museum"
},
{
"id": "…",
"title": "Why did the Challenger explode?",
"date": "2016-11-31 18:30:00+01:00",
"venue": "Albert II Library Building"
}
]
Now you want to sort this by date, let's say you have a forum or something similar, where the first post should be the newest according to date.
How would you sort it? This was my attempt:
function sortExample (dataToSort) {
let test = [...dataToSort];
test.sort((a,b) => new Date(a.date) - new Date(b.date)
}
Unfortunately, that didn't work out quite well. And most importantly, where would you place this function? I put it in my useEffect, that didn't seem to work for me either.
You have two options:
Sort it before you setState, this is recommended if you only need the data in this order everywhere.
Sort it where you use the data but keep the original data as is in the state.
Say we have:
const [dataInState, setDataInState] = useState([]);
First one:
useEffect(() => {
fetch("/data")
.then(res => res.json())
.then(json => setDataInState(json.sort(myCompareFunc))
}, [])
Second one:
Set state as usual, then in component body, before return:
const sortedData = [...dataInState].sort(myCompareFunc)
Now you can use sortedData in JSX or elsewhere.
I recommend the first one due to performance reasons.
So this is an example data array that I will get back from backend. There are a few use cases as shown below and I want to target based on the subscription values in the array.
Example: 1
const orgList = [
{ id: "1", orgName: "Organization 1", subscription: "free" },
{ id: "2", orgName: "Organization 2", subscription: "business" },
];
In the example 1 - when array comes back with this combination - there will be some styling and text to target the element with subscription: free to upgrade its subscription
Example 2:
const orgList = [
{ id: "1", orgName: "Organization 1a", subscription: "pro" },
{ id: "2", orgName: "Organization 2a", subscription: "business" },
];
Example 3:
const orgList = [
{ id: "1", orgName: "Organization 1b", subscription: "free" },
];
In the example 3 - when array comes back with only one element - there will be some styling and text to target the element say to upgrade its subscription
At the moment, I'm simply using map to go over the array that I get back like so:
{orgList.map((org) => (...do something here)} but with this I'm a bit limited as I don't think this is the best way to handle the 3 use cases / examples above.
Another idea is too do something like this before mapping but this:
const freeSubAndBusinessSub = org.some(org => org.subscription === 'free' && org.subscription === "business")
but doesn't seem to work as it returns false and then I'm stuck and not sure how to proceed after..
So my question is what's the best way to approach this kind of array to target what do to with the elements based on their values?
You mention that using .map() is limited, but you don't expand on it. Logically what it sounds like you want is a separate list for each type to act upon. You can accomplish this using .filter() or .reduce(), however, in this case .map() is your friend.
// Example 1
const free = orgList.filter(org => org.subscription === 'free');
const business = orgList.filter(org => org.subscription === 'business');
free.map(org => /* do free stuff */);
business.map(org => /* do business stuff */);
// Example 2
const subscriptions = orgList.reduce((all, cur) => {
if (!all.hasOwnProperty(cur.subscription)) {
all[cur.subscription] = [];
}
all[cur.subscription].push(cur);
return all;
}, {});
subscriptions['free'].map(org => /* do free stuff */);
subscriptions['business'].map(org => /* do business stuff */);
// Example 3
orgList.map(org => {
switch(org.subscription) {
case 'free':
/* do free stuff */
break;
case 'business':
/* do business stuff */
break;
}
})
You'll notice that in all the examples, you still need to map on the individual orgs to perform your actions. Additionally, with the first two examples, you'll be touching each element more than once, which can be incredibly inefficient. With a single .map() solution, you touch each element of the list only once. If you feel that you do free stuff actions become unwieldy, you can separate them out in separate functions.
I am trying to follow the example of cursor-based paginating with React Apollo (https://www.apollographql.com/docs/react/data/pagination/#cursor-based) but am struggling with how my component that rendered the original data gets the new (appended) data.
This is how we get the original data and pass it to the component:
const { data: { comments, cursor }, loading, fetchMore } = useQuery(
MORE_COMMENTS_QUERY
);
<Comments
entries={comments || []}
onLoadMore={...}
/>
What I'm unsure of is how the fetchMore function works.
onLoadMore={() =>
fetchMore({
query: MORE_COMMENTS_QUERY,
variables: { cursor: cursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
const previousEntry = previousResult.entry;
const newComments = fetchMoreResult.moreComments.comments;
const newCursor = fetchMoreResult.moreComments.cursor;
return {
// By returning `cursor` here, we update the `fetchMore` function
// to the new cursor.
cursor: newCursor,
entry: {
// Put the new comments in the front of the list
comments: [...newComments, ...previousEntry.comments]
},
__typename: previousEntry.__typename
};
}
})
}
From what I understand, yes, once my component will cal this onLoadMore function (using a button's onClick for example), it will fetch the data based on a new cursor.
My question is this. I'm sorry if this is too simple and I'm not understanding something basic.
How does the component get the new data?
I know the data is there, because I console logged the newComments (in my case, it wasn't newComments, but you get the idea.) And I saw the new data! But those new comments, how are they returned to the component that needs the data? And if I click the button again, it is still stuck on the same cursor as before.
What am I missing here?
In the updateQuery function lets you modify (override) the result for the current query. At the same time your component is subscribed to the query and will get the new result. Let's play this through:
Your component is rendered for the first time, component will subscribe to the query and receive the current result of the query from the cache if there is any. If not the query starts fetching from the GraphQL server and your component gets notified about the loading state.
If the query was fetched your component will get the data once the result came in. It now shows the first x results. In the cache an entry for your query field is created. This might look something like this:
{
"Query": {
"cursor": "cursor1",
"entry": { "comments": [{ ... }, { ... }] }
}
}
// normalised
{
"Query": {
"cursor": "cursor1",
"entry": Ref("Entry:1"),
}
"Entry:1": {
comments: [Ref("Comment:1"), Ref("Comment:2")],
},
"Comment:1": { ... },
"Comment:2": { ... }
}
User clicks on load more and your query is fetched again but with the cursor value. The cursor tells the API from which entry it should start returning values. In our example after Comment with id 2.
Query result comes in and you use the updateQuery function to manually update the result of the query in the cache. The idea here is that we want to merge the old result (list) with the new result list. We already fetched 2 comments and now we want to add the two new comments. You have to return a result that is the combined result from two queries. For this we need to update the cursor value (so that we can click "load more" again and also concat the lists of comments. The value is written to the cache and our normalised cache now looks like this:
{
"Query": {
"cursor": "cursor2",
"entry": { "comments": [{ ... }, { ... }, { ... }, { ... }] }
}
}
// normalised
{
"Query": {
"cursor": "cursor2",
"entry": Ref("Entry:1"),
}
"Entry:1": {
comments: [Ref("Comment:1"), Ref("Comment:2"), Ref("Comment:3"), Ref("Comment:4")],
},
"Comment:1": { ... },
"Comment:2": { ... },
"Comment:3": { ... },
"Comment:4": { ... }
}
Since your component is subscribed to the query it will get rerendered with the new query result from the cache! The data is displayed in the UI because we merged the query so that the component gets new data just as if the result had all four comments in the first place.
It depends on how you handle the offset. I'll try to simplify an example for you.
This is a simplified component that I use successfully:
const PlayerStats = () => {
const { data, loading, fetchMore } = useQuery(CUMULATIVE_STATS, {
variables: sortVars,
})
const players = data.GetCumulativeStats
const loadMore = () => {
fetchMore({
variables: { offset: players.length },
updateQuery: (prevResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return prevResult
return {
...prevResult,
GetCumulativeStats: [
...prevResult.GetCumulativeStats,
...fetchMoreResult.GetCumulativeStats,
],
}
},
})
}
My CUMULATIVE_STATS query returns 50 rows by default. I pass the length of that result array to my fetchMore query as offset. So when I execute CUMULATIVE_STATS with fetchMore, the variables of the query are both sortVars and offset.
My resolver in the backend handles the offset so that if it is, for example, 50, it ignores the first 50 results of the query and returns the next 50 from there (ie. rows 51-100).
Then in the updateQuery I have two objects available: prevResult and fetchMoreResult. At this point I just combine them using spread operator. If no new results are returned, I return the previous results.
When I have fetched more once, the results of players.length becomes 100 instead of 50. And that is my new offset and new data will be queried the next time I call fetchMore.
I'm aware of "sacred" data is and how dangerous it might get if handled incorrectly; that's why in my app, I'm in a position now where I need to handle a nested long JSON object (that repsents my app state) and it's already a headache to me to get in and out of the nodes/values that need to be amended. I was thinking of including Immutable.js. The question now is: how do I adapt my reducers, actions, state, etc?
Here's an extract of my state when it comes from the MongoDB database:
"shops": [
{
"shopId": "5a0c67e9fd3eb67969316cff",
"picture": "http://placehold.it/150x150",
"name": "Zipak",
"email": "leilaware#zipak.com",
"city": "Rabat",
"location": {
"type": "Point",
"coordinates": [
-6.74736,
33.81514
]
}
},
{
"shopId": "5a0c6b55fd3eb67969316d9d",
"picture": "http://placehold.it/150x150",
"name": "Genekom",
"email": "leilaware#genekom.com",
"city": "Rabat",
"location": {
"type": "Point",
"coordinates": [
-6.74695,
33.81594
]
}
},
...
When a certain action (end-user hits a "Like" button) is triggered, I need to add an attribute to the related Shop object, so it'd become like this:
{
"shopId": "5a0c67e9fd3eb67969316cff",
"picture": "http://placehold.it/150x150",
"name": "Zipak",
"email": "leilaware#zipak.com",
"city": "Rabat",
"liked": true,
"location": {
"type": "Point",
"coordinates": [
-6.74736,
33.81514
]
}
},
Now, I manage to add the liked attribute to the individual Shop object, but when I want to change/amend the state (group of all Shop objects), I get the object duplicated (the new one with the liked attribute and the old one). To avoid this mess, I want to handle these operations using Immutable.js to make sure everything is clean and proper.
Where do I start from? Do I convert the state to an Immutable.js Map? List?
Rather than using Immutable.js you can use immutability-helper which has an update function returns the updated value without mutating the original value.
immutability-helper has a syntax inspired from MongoDB so getting used to it shouldn't be very hard for you.
For your case, you can modify your state with the below sample,
import update from 'immutability-helper';
const id = '5a0c67e9fd3eb67969316cff'; // id of the item you need to update
const newState = update(state, {
shops: {
$apply: (item) => {
if (item.shopId !== id) return item
return {
...item,
liked: true
}
}
}
});
Simply reduce your array of identified shops to an object on fetch success. It's a good practice to do so for any list of unique items to avoid the O(n) complexity of find functions and to mutate your state in a simpler way.
Reduce shops on fetch success in your shops reducer :
shops.reduce((obj, item) => {
obj[item.id] = item
return obj;
}, {})
It will then be easy to manipulate your object inside your shops reducer on any action such as a like success :
return {
...state,
[action.shopId]: {
...state[action.shopId],
liked: true,
},
}
let's say I have following tree:
[
{
name: 'asd',
is_whatever: true,
children: [
{
name: 'asd',
is_whatever: false,
children: [],
},
],
},
],
The tree is stored in a module via Vuex under key 'tree' and looped through with following recursive component called 'recursive-item':
<li class="recursive-item" v-for="item in tree">
{{ item.name }}
<div v-if="item.is_whatever">on</div>
<div v-else>off</div>
<ul v-if="tree.children.length">
<recursive-item :tree="item.children"></recursive-item>
</ul>
</li>
Now i want to toggle item's property 'is_whatever', so i attach a listener
<div v-if="item.is_whatever"
#click="item.is_whatever = !item.is_whatever">on</div>
<div v-else>off</div>
When i click it, it works, but emits following
"Error: [vuex] Do not mutate vuex store state outside mutation handlers."
[vuex] Do not mutate vuex store state outside mutation handlers.
How am I supposed to implement it without this error? I can see no way how to dispatch an action or emit event to the top of the tree because it's nested and recursive, so I haven't got a path to the specific item, right?
After consulting with some other devs later that evening we came with few ways how to achieve it. Because the data are nested in a tree and I access the nodes in recursive manner, I need to either get the path to the specific node, so for example pass the index of a node as a property, then add the child index while repeating that in every node recursively, or pass just the id of a node and then run the recursive loop in the action in order to toggle its properties.
More optimal solution could be flattening the data structure, hence avoiding the need for a recursion. The node would be then accessible directly via an id.
Right now you're changing the state object directly by calling item.is_whatever = !item.is_whatever, what you need to do is create a mutation function that will execute that operation for you to guarantee proper reactivity:
const store = new Vuex.Store({
state: { /* Your state */ },
mutations: {
changeWhatever (state, item) {
const itemInState = findItemInState(state, item); // You'll need to implement this function
itemInState.is_whatever = !item.is_whatever
}
}
})
Then you need to expose this.$store.commit('changeWhatever', item) as an action in your view that'll be trigger by the click.
There is a debatable solution, but I'll just leave it here.
State:
state: {
nestedObject: {
foo: {
bar: 0
}
}
}
There is Vuex mutation:
mutateNestedObject(state, payload) {
const { callback } = payload;
callback(state.nestedObject);
},
And this is an example of use in a component:
this.$store.commit('mutateNestedObject', {
callback: (nestedObject) => {
nestedObject.foo.bar = 1;
},
});