📜 ⬆️ ⬇️

A story about how to create a repository and understand Redux

Redux is an interesting template, and, at its core, it is very simple. But why is it hard to understand? In this article we will look at the basic concepts of Redux and deal with the internal mechanisms of storage. Having understood these mechanisms, you will be able to familiarize yourself with everything that happens, as they say, “under the hood” of Redux, namely, with how the storages, reducers and actions work. This will help you to bring application debugging to a new level, will help to write better code. You will know exactly what functions this or that line of your program performs. We will go to the understanding of Redux through a practical example, which is to create your own repository using TypeScript.

image

This material is based on the Redux source code , written in pure TypeScript. The author offers everyone to look at this code and deal with it. However, he points out that this project is intended for educational purposes.

Terminology


If you only recently started learning Redux, or just flipped through the documentation , you must have come across some terms that I think should be considered before we get to the point.
')

â–Ť Actions


Do not attempt to perceive actions as a JavaScript API. Action has a definite goal - and this needs to be understood first. Actions inform the repository of intent.

Working with the repository, he is given instructions, for example, something like this: “Hey, repository! I have a request for you. Please update the status tree by adding this data to it. ”

The action signature, when using TypeScript to show it, looks like this:

interface Action {  type: string;  payload?: any; } 

Payload (payload) - this is an optional property, because sometimes we can send to the store actions that do not take the payload, although in most cases this property is involved. Here we have in mind that when creating an action, we describe it, for example, as follows:

 const action: Action = { type: 'ADD_TODO', payload: { label: 'Eat pizza,', complete: false }, }; 

This is essentially an action template. But more about that after, but for now - continue familiarity with the terminology.

â–Ť Reducers


A reducer (reducer) is just a pure function that accepts the state of the application (the internal state tree that the repository transfers to the reducer), and, as a second argument, the action sent to the repository. That is, it looks like this:

 function reducer(state, action) { //...    } 

So, what else do you need to know about the reducer? The reduer, as we know, takes a state, and in order to do something useful (like updating the state tree), we need to respond to the type property of the action (we just saw this property). This is usually done using the switch construction:

 function reducer(state, action) { switch (action.type) {   case 'ADD_TODO': {     // ,   - ...   } } } 

Each case branch inside the switch allows you to respond to different types of actions that are involved in shaping the state of the application. For example, suppose we need to add a property with some value to the state tree. To do this, we perform some actions and return the changed state:

 function reducer(state = {}, action) { switch (action.type) {   case 'ADD_TODO': {     return {       ...state,       //  ,    ,   todos         //                todos: [...state.todos, { label: 'Eat pizza,', complete: false }],     };   } } return state; } 

Notice that there is a state return command at the bottom of the code. This is done in order to return the initial state if there is no case branch in the reducer corresponding to some action. Here you can add that the construction state = {} is added as the first argument. This is the default value of the parameter. The original state objects are usually formed outside the limits of the reducer, we will talk about this later.

The last thing you need to pay attention to is the desire for immunity. We return a completely new object in each branch of the case . It is a combination of the previous state and the changes made to it. As a result, the output is a slightly modified version of the original state tree. Here, first, apply the command …state , the extension operator, after which new properties are added to the current state.

By following the concept of pure functions, we ensure that the same input data always leads to the same output data. Reducers are pure functions that handle a dynamic state based on actions. Simply put, we customize them, and everything else is done in the process. They encapsulate functions that contain the logic necessary to update the state tree based on the instructions (actions) that we pass to them.

Reducers are synchronous functions, asynchronous behavior should be avoided inside them.
So, when does action.payload come into action.payload ? Ideally, you should not rigidly set certain values ​​in the reducer, unless these are some simple things like transferring a logical value from false to true . Now, in order to complete the theme of handling actions, we use the action.payload property, accessible due to the action passed to the editor when it is called, and we get the necessary data:

 function reducer(state = {}, action) { switch (action.type) {   case 'ADD_TODO': {     //        const todo = action.payload;     //         const todos = [...state.todos, todo];     //         return {       ...state,       todos,     };   } } return state; } 

â–ŤStorage


I constantly have to see how the state is confused with the store. The storage is a container, and the state is simply placed in this container.

The storage is an object with an API that allows you to interact with the state, modify it, read its values, and so on.

I think we are almost ready to start creating our own repository, and everything that we have just talked about, while looking fragmented, will fall into place.

I would like to note that, in essence, the functions of the repository consist in the implementation of a structured process of updating properties in an object. In fact, this is Redux.

Storage API


Our Redux learning repository will have just a few publicly available properties and methods. Then we will use the storage as shown below, passing it the reduction gears and the initial state for the application:

 const store = new Store(reducers, initialState); 

Store Store.dispatch () method


Method
 dispatch 
allow us to give instructions to the repository, informing it that we intend to change the state tree. This operation is performed by means of a reducer, as we have already discussed above.

Store Store.subscribe () method


The subscribe method allows organizing the transfer to the repository of subscriber functions interested in changing the state tree. The corresponding information is transferred to these functions when the state tree changes.

Store Store.value property


The value property will be configured as a getter , it returns the internal state tree (as a result, we will be able to access the properties).

Storage container


As we already know, the repository contains a state, and also allows us to send it actions that need to be performed on the state tree. It allows you to subscribe to updates. Let's start working on the Store class:

 export class Store { constructor() {} dispatch() {} subscribe() {} } 

So far, everything looks quite normal, but we have forgotten about the object for the state, state . Add it:

 export class Store { private state: { [key: string]: any }; constructor() {   this.state = {}; } get value() {   return this.state; } dispatch() {} subscribe() {} } 

I like to write in TypeScript, here I also use its mechanisms to indicate that the state object will consist of string keys, which can correspond to values ​​of any type. This is exactly what you need to work with our data structures.

In addition, the method get value() {} added here, which returns a state object when it is accessed as a property:

 console.log(store.value); 

So now create an instance of the repository:

 const store = new Store(); 

At the moment, it is quite possible to call the dispatch method:

 store.dispatch({ type: 'ADD_TODO', payload: { label: 'Eat pizza', complete: false }, }); 

However, such a call still does not lead to anything, so let's do the work on the dispatch method, we will bring it to this form so that it can transfer the action to it:

 export class Store { // ... dispatch(action) {   //     ! } // ... } 

So, in the dispatch method, you need to update the state tree. But first, let us ask ourselves - what does it look like - is this a state tree?

â–ŤData structure for storing state


For the purposes of this material, the state data structure will look like this:

 { todos: {   data: [],   loaded: false,   loading: false, } } 

Why? We already know that reducers update the state tree. In a real application, we would have many reducers that are responsible for updating some of the state tree. These parts are often called "layers" of the state. Each such layer is controlled by a certain reducer.

In this case, the todo property in the state tree, or the todo property layer, will be controlled by the reducer. At the moment, our reducer will work with the data , loaded , and loading properties. Here, the loaded (loaded) and loading (loaded) properties are used, because when an asynchronous operation is performed, like loading JSON over HTTP, we would like to control various steps that are performed as part of this operation - from initiating a request to its successful completion.

We continue work on the dispatch method.

â–ŤUpdate the state tree


In order to follow the immutable update pattern, we need to assign a new state representation to the state property in the form of a completely new object. This new object includes the changes we wanted to make in the state tree using an action.

In this example, for the time being we will forget about the existence of reducer and simply update the state manually:

 export class Store { // ... dispatch(action) {   this.state = {     todos: {       data: [...this.state.todos.data, action.payload],       loaded: true,       loading: false,     },   }; } // ... } 

After we send the action 'ADD_TODO' to the dispatch method, the status will look like this:

 { todos: {   data: [{ label: 'Eat pizza', complete: false }],   loaded: false,   loading: false, } } 

Development of reyuser


Now that we know that the reducer updates a layer of state, we will describe this layer:

 export const initialState = { data: [], loaded: false, loading: false, }; 

â–ŤCreation of a reducer


Now you need to pass to the function of the reducer the state argument, the default value of which is the above-described initialState object. This allows preparing the reducer for the first boot when we call the reducer in the storage in order to connect all the reducers with the initial state:

 export function todosReducer( state = initialState, action: { type: string, payload: any } ) { //     return state; } 

At the moment, given what we already know about reducer, you can understand how to extend the code further:

 export function todosReducer( state = initialState, action: { type: string, payload: any } ) { switch (action.type) {   case 'ADD_TODO': {     const todo = action.payload;     const data = [...state.data, todo];     return {       ...state,       data,     };   } } return state; } 

Well, as long as everything goes fine, but the reducer needs to connect to the repository in order to be able to call it, passing it the state and the action to be performed on it.

Let's go back to the Store object:

 export class Store { private state: { [key: string]: any }; constructor() {   this.state = {}; } get value() {   return this.state; } dispatch(action) {   this.state = {     todos: {       data: [...this.state.todos.data, action.payload],       loaded: true,       loading: false,     },   }; } } 

We need to make it possible to add reducer to the storage:

 export class Store { private state: { [key: string]: any }; private reducers: { [key: string]: Function }; constructor(reducers = {}, initialState = {}) {   this.reducers = reducers;   this.state = {}; } } 

In addition, we provide the initial state to the repository, the initialState , so we can, if desired, transfer it when we create the repository.

Registration Reducer


In order to register a reducer, we must remember that the todos property is in the state tree, and we must attach a reducer function to it. Let me remind you, we are going to work with the state layer called todos :

 const reducers = { todos: todosReducer, }; const store = new Store(reducers); 

Here is the most interesting, and usually incomprehensible. Namely, here the todos property becomes the result of a call to the todosReducer reducer todosReducer , which, as we know, returns a new state based on some action.

â–ŤCalling Reducers in Storage


The principle of operation of reduser, in its essence, resembles the work of the function Array.prototype.reduce , which leads the array processed to it to a certain single value. Reducers work in a similar way, taking the old state, performing some actions on it, and returning a new state.

Now we are going to wrap the logic of the reducer in a function called reduce here:

 export class Store { // ... dispatch(action) {   this.state = this.reduce(this.state, action); } private reduce(state, action) { //        return {}; } } 

When we pass an action to the repository, we actually call the reduce method of the Store class that we just created, and pass it the state and the action. This design is called the root reduction gear. You can see that it takes state and action - just as it does todosReducer .

Now let's talk about the private reduce method, since this is the most important step in building a state tree and bringing together everything we are talking about here.

 export class Store { private state: { [key: string]: any }; private reducers: { [key: string]: Function }; constructor(reducers = {}, initialState = {}) {   this.reducers = reducers;   this.state = {}; } dispatch(action) {   this.state = this.reduce(this.state, action); } private reduce(state, action) {   const newState = {};   for (const prop in this.reducers) {     newState[prop] = this.reducers[prop](state[prop], action);   }   return newState; } } 

This is what happens here:


The value of prop in this case is just todos , so all this can be viewed as:

 newState.todos = this.reducers.todos(state.todos, action); 

â–ŤProcessing initialState with a reducer


Now we just have to talk about the initialState object. If we are going to use a record of the type Store(reducers, initialState) to prepare the initial state of the entire storage, we need to process it with the reducer during the creation of the storage:

 export class Store { private state: { [key: string]: any }; private reducers: { [key: string]: Function }; constructor(reducers = {}, initialState = {}) {   this.reducers = reducers;   this.state = this.reduce(initialState, {}); } // ... } 

Remember how we told that at the end of the code of each reducer there should be a command like return state ? Now you know why. We have this so that we can pass an empty object as an action, {} , meaning that the branches of the switch will be skipped, and as a result we will have a state tree obtained through the constructor .

Subscriber Tools


You will often come across the term “subscriber” in the world of surveyed objects, where every time the surveyed object generates a new value, we are notified of this through a subscription. A subscription is a kind of request: “give me the data when it is available or change.”

In our case, working with subscription mechanisms will look like this:

 const store = new Store(reducers); store.subscribe(state => { //  - `state` }); 

Store followers


Add a few more properties to the repository that allow you to customize the subscription mechanism:

 export class Store { private subscribers: Function[]; constructor(reducers = {}, initialState = {}) {   this.subscribers = [];   // ... } subscribe(fn) {} // ... } 

Here is the subscribe method, which now takes the function ( fn ) as an argument. Now we need to pass each such function to the subscribers array:

 export class Store { // ... subscribe(fn) {   this.subscribers = [...this.subscribers, fn]; } // ... } 

This, as you can see, was simple. And where can we tell our subscribers that something has changed? Of course, in the dispatch method!

 export class Store { // ... get value() {   return this.state; } dispatch(action) {   this.state = this.reduce(this.state, action);   this.subscribers.forEach(fn => fn(this.value)); } // ... } 

And this, again, is simple. Every time we call dispatch , we pass a state to the reduce method and bypass the subscribers, passing it this.value (remember that the getter value triggered here).

Now we need to solve only one problem. When we call .subscribe() , we do not want (at this particular moment) to get the value of state . We want to get it after executing the dispatch method. Therefore, we will decide to inform new subscribers about the current status as soon as they subscribe:

 export class Store { // ... subscribe(fn) {   this.subscribers = [...this.subscribers, fn];   fn(this.value); } // ... } 

Here we take the function passed through the subscribe method and, after making the subscription, we call it with the transfer of the state tree to it.

Review of the repository


We can subscribe to changes in the repository, but it would be nice to implement the reverse mechanism. Cancellation may be necessary, for example, in order to avoid excessive memory use, or due to the fact that certain changes in the storage do not interest us anymore.

All you need to do here is return the closure, which, when called, will remove the function from the list of subscribers:

 export class Store { // ... subscribe(fn) {   this.subscribers = [...this.subscribers, fn];   fn(this.value);   return () => {     this.subscribers = this.subscribers.filter(sub => sub !== fn);   }; } // ... } 

Here we use the function reference, iterate through the subscribers, check if the current subscriber is equal to our fn . Next, using Array.prototype.filter , what is no longer needed is removed from the subscriber array. You can use it like this:

 const store = new Store(reducers); const unsubscribe = store.subscribe(state => {}); destroyButton.on('click', unsubscribe, false); 

And that's all we need.

The beauty of the subscription mechanism is that we can have many subscribers, which means that different parts of our application may be interested in different layers of the state.

Full repository code


Here is the complete code of what we did:

 export class Store { private subscribers: Function[]; private reducers: { [key: string]: Function }; private state: { [key: string]: any }; constructor(reducers = {}, initialState = {}) {   this.subscribers = [];   this.reducers = reducers;   this.state = this.reduce(initialState, {}); } get value() {   return this.state; } subscribe(fn) {   this.subscribers = [...this.subscribers, fn];   fn(this.value);   return () => {     this.subscribers = this.subscribers.filter(sub => sub !== fn);   }; } dispatch(action) {   this.state = this.reduce(this.state, action);   this.subscribers.forEach(fn => fn(this.value)); } private reduce(state, action) {   const newState = {};   for (const prop in this.reducers) {     newState[prop] = this.reducers[prop](state[prop], action);   }   return newState; } } 

As you can see, everything is not so difficult.

Results


It is possible that you heard about all the mechanisms that we talked about today, or even used them, but were not interested in how they work. I hope, creating your own repository, you understand how everything that it consists of works. There is nothing mysterious in the work of actions and reducers. The dispatch method informs the repository about the need to perform the process of defining a new state by calling each reducer and attempting to match action.typ e with one of the branches of the switch . And the state tree is the final view of what comes after calling all the reducers.

Thanks to the example I shared with you, I finally understood Redux. I hope he will help you with this.

Dear readers! How do you master new technologies?

Source: https://habr.com/ru/post/345340/


All Articles