DEV Community 👩‍💻👨‍💻

IJ
IJ

Posted on • Updated on

Learn Redux by writing your own implementation

What is the purpose of this blog?

We'll create our own (basic) version of Store, Reducer, Provider and Connect, by understanding what they are meant to do, how they are achieving that task, and then finally stitch them together to make the complete flow work.

Why are we doing this?

I've been using Redux for a long time but the internal working of it was always a mystery to me. I knew I had to create a reducer, what it is meant to be, wrap my Application with a Provider component, use Connect HOC to wrap my component so that the state of the store gets properly allocated to my component etc. But how does each of this component work, was never understood.

And I figured the best way to learn something is to try building it on my own.

How will we build it?

So we need to have a basic React App on top of which we will create the Redux parts one by one.

For that we will take the scenario of two buttons and two labels. Clicking on button 1 will increase the value of the label 1, and similarly the button 2 will increment label 2 value.

We will be using React Functional components and use useState for the components' internal state. And the label 1 and 2's values together will form the entire state of the app. And it will reside in our store.

And cue the music...

Step 0: Create a react app like this:
App.js

import React from "react";

export default function App() {
    return (
        <div className="App">
            <CountButton />
            <Count />
            <br />
            <AgeButton />
            <Age />
        </div>
    );
}

const CountButton = () => <button>Increment count</button>;
const Count = (props) => <div>Count: {props.count}</div>;

const AgeButton = () => <button>Increment age</button>;
const Age = (props) => <div>Age: {props.age}</div>;
Enter fullscreen mode Exit fullscreen mode

And it will render something like this:

Screenshot 2021-05-15 at 12.21.17 AM

Next we need a Store (or createStore class), which will store the state of the app, accept bunch of listeners who want to listen to any state change, have a mechanism to dispatch an action fired by any of the components to these listeners.

Step 1: Create a CreateStore class

To create a store creating function, lets ask what all that method would need? Whats the syntax we use?

const Store = new CreateStore(Reducer, INITIAL_STATE);
Enter fullscreen mode Exit fullscreen mode

Looks like CreateStore accepts a reducer and a state object as the initial state. So lets create those two things.

InitialState.js

const INITIAL_STATE = {
    count: 0,
    age: 0
};

export default INITIAL_STATE;
Enter fullscreen mode Exit fullscreen mode

What is reducer? Simply put, its a function that accepts an action emitted by the components and does something to the state and returns a new state. That means, it has to accept an action the current state.

This modified state is returned from the reducer which replaces the original state of the store (hence we say redux does not mutate state, it instead creates new copies of it).

So lets create a reducer.

Reducer.js

const Reducer = function(action, state){
    switch(action.type){
        case 'INCREMENT_AGE':
            return { ...state, age: state.age + action.data }
            break;
        case 'INCREMENT_COUNT':
            return { ...state, count: state.count + action.data }
            break;
        default:
            return { ...state };
    }
}

export default Reducer;
Enter fullscreen mode Exit fullscreen mode

The reducer above can receive all the actions emitted by all the components. Which means it could be dealing with multiple actions. Hence we have kept a switch case to match the action type. Depending on the action.type we create a new state object from the existing state using the action.data. And we we made sure we only modify the key corresponding to the action.type. You can also use Object.assign instead of the spread syntax I've used.

Now that we have both the arguments needed to create CreateStore function, lets get to it.

A store would have to maintain a list of subscribers, and the current state. Also, since we have subscribers, we should have a method to accept those subscribers. Store should also provide a method to the components with which they can dispatch an action. And this dispatch methiod should accept an action, because components do invoke actions when things happen in the UI.

CreateStore.js

export default class CreateStore {
    constructor(reducer, initialState = {}) {
        this.subscribers = [];
        this.reducer = reducer;
        this.state = initialState;
    }

    subscribe(subscriber) {
        this.subscribers.push(subscriber);
    }

    dispatch(action) {
        const newState = this.reducer(action, this.state);
        this.state = newState;
        this.subscribers.forEach((subscriber) => subscriber(this.state));
    }

    getState() {
        return this.state;
    }
}

Enter fullscreen mode Exit fullscreen mode

constructor: simply accepts the params and stores it in the corresponding instance variables. And declares an array for storing the subscribers.

subscribe: it accepts a callback function as subscriber and pushes it to the array of subscribers.

dispatch: it accepts an action invoked by the component, uses that action to invoke the reducer (and passes the state of the store to the reducer), and obtains a new state. This new state becomes the state of the store. Then as a final step, inform all the listeners about this new state change.

Step 2: Create a new instance of the store, by using the things we created so far.
Store.js

import CreateStore from "./CreateStore";
import Reducer from "./Reducer";
import INITIAL_STATE from "./InitialState";

const Store = new CreateStore(Reducer, INITIAL_STATE);

export default Store;
Enter fullscreen mode Exit fullscreen mode

Next, we need to be able to make this Store available to the App.js components. For that we need something called Provider. Lets try writing one.

Step 3: Provider

Just like the name suggests, it provides the store to the Components. It accepts the store as a prop. To make the store available to its child components, in the past, we used React.CloneElement. But now that we have the Context API, it is much more efficient since we dont need to clone the children. We wont be going into how Context API works because it is out of the scope of this blog. You can read about it here

We will be using the Context API to create a StoreContext with our Store as the value. And from the Children we will be able to access this Store instance using the same StoreContext.

Provider.js

import React, { createContext } from "react";

const StoreContext = createContext(null);

const Provider = function (props) {
    return <StoreContext.Provider value={props.store}>{props.children}</StoreContext.Provider>;
};

export default Provider;
export { StoreContext };
Enter fullscreen mode Exit fullscreen mode

We will not be referring to the Store instance directly from the Provider, because we want the Provider to work as a re-usable component that is unaware of the store. Instead, we expect anyone who is using the Provider to pass the Store instance as a prop to the Provider. And that prop.store will be used in the StoreContext.

We will also be exporting the StoreContext object so that we can import them wherever we need to access the Store instance.

Step 4: Wrap the App component with our Provider

Now we take the App.js we wrote in the beginning and wrap it with our Provider.

import React from "react";
import Store from "./Store";
import Provider from "./Provider";

export default function App() {
    return (
        <Provider store={Store}>
            <div className="App">
                <CountButton />
                <Count />
                <br />
                <AgeButton />
                <Age />
            </div>
        </Provider>
    );
}

const CountButton = () => <button onClick={incrementCount}>Increment count</button>;
const Count = (props) => <div>Count: {props.count}</div>;

const AgeButton = () => <button onClick={incrementAge}>Increment age</button>;
const Age = (props) => <div>Age: {props.age}</div>;

const incrementCount = () => Store.dispatch({ type: "INCREMENT_COUNT", data: 1 });
const incrementAge = () => Store.dispatch({ type: "INCREMENT_AGE", data: 1 });
Enter fullscreen mode Exit fullscreen mode

Along with this, I have taken the liberty of adding two event handlers, incrementCount and incrementAge. They use the Store instance to dispatch actions when the user clicks the corresponding buttons.

At this stage, our data flow is ready as such, the actions that are triggered by the eventHandlers reach till the Store and the reducer. If you put debugger in the Reducer's code, you should be seeing the actions reaching there and updating the state. Go ahead! Do check!

Now what's missing is, the updated the state from the store should reach back these components. And for that, we need the Connect component.

Step 5: Connect HOC

Now we need to connect the Store, Provider with the components. For that we create a Connect Higher Order Component. It will take the component that need to be updated when the store's state updates, and return a component with its own life cycle methods.

Connect.js

import React from "react";
import { StoreContext } from "./Provider";

export default function Connect(Comp) {
    return class extends React.Component {
        static contextType = StoreContext;

        componentDidMount() {
            const store = this.context;
            this.setState(store.getState());
            store.subscribe((stateFromStore) => {
                console.log({ stateFromStore });
                this.setState(stateFromStore);
            });
        }

        render() {
            return <Comp {...this.state} />;
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

What we've done above may look a bit complicated. But actually what it is doing is - create a Higher Order Component (HOC) which takes a Component as its argument. Then return a class based Component. The statement static contextType = StoreContext; is a way of reading the StoreContext value and making it available on the instance.

Then we've added the componentDidMount, which reads the Store from the instance, then read the initialState of the Store and set it as the state of the Component we're returning. That means the INITIAL_STATE we stored in the Store becomes the state of this Component.

Along with this, we're subscribing a method to the Store, via store.subscribe. So whenever the Store gets updated via the actions, and Store updates its listeners, the anonymous function which we pass as the subscriber, gets invoked and it receives the latest state from the Store.

Now in our render method, we return the original Component which we accepted as the argument to the HOC. Along with it we spread and pass the entire state as params.

To complete this step, we also need to wrap our Components with this Connect HOC. So our App.js becomes -

import React from "react";
import Store from "./Store";
import Provider from "./Provider";
import Connect from "./Connect";

export default function App() {
    return (
        <Provider store={Store}>
            <div className="App">
                <CountButton />
                <Count />
                <br />
                <AgeButton />
                <Age />
            </div>
        </Provider>
    );
}

const CountButton = () => <button onClick={incrementCount}>Increment count</button>;
const Count = Connect((props) => <div>Count: {props.count}</div>);

const AgeButton = () => <button onClick={incrementAge}>Increment age</button>;
const Age = Connect((props) => <div>Age: {props.age}</div>);

const incrementCount = () => Store.dispatch({ type: "INCREMENT_COUNT", data: 1 });
const incrementAge = () => Store.dispatch({ type: "INCREMENT_AGE", data: 1 });
Enter fullscreen mode Exit fullscreen mode

At this stage, all the Components that are wrapped by the Connect should be getting the entire state of the Store on every Store update.

You can stay and read on if you'd like to know how to add mapStateToProps as an argument to Connect, so that only the keys that you want from the state will be mapped to the props.

We dont want the entire state to be given to all the Components that are wrapped by Connect. It would be cleaner if we can pass only the required keys from the state as props to the components. Thats the purpose of mapStateToProps; it helps the Connect to map only the specified keys from the state to the corresponding Component.

Lets do that in the next step.

Step 6: mapStateToProps

mapStateToProps is like a callback function we pass to the Connect as the second argument, which expects a state object as its own param, and extract out the desired keys from it, and then return it.

This function mapStateToProps will be used by Connect itself. Connect will pass the entire state to this function, and that function knows which key(s) it need to extract out from the entire state. And that state becomes the props to the Component returned by the Connect.

App.js (showing only the affected component)
const Count = Connect(
    (props) => {
        return <div>Count: {props.count}</div>;
    },
    (state) => {
        const { count } = state;
        return { count };
    }
);

const Age = Connect(
    (props) => {
        return <div>Age: {props.age}</div>;
    },
    (state) => {
        const { age } = state;
        return { age };
    }
);
Enter fullscreen mode Exit fullscreen mode

Modify Connect.js to accept mapStateToProps, and process the state received from Store using mapStateToProps, use that as the state of the returned Component, and finally spread it out to make it the props of the original Component.

Connect.js

export default function Connect(Comp, mapStateToProps = (state) => state) {
    return class extends React.Component {
        static contextType = StoreContext;

        componentDidMount() {
            const store = this.context;
            const firstState = mapStateToProps(store.getState());
            this.setState(firstState);
            store.subscribe((stateFromStore) => {
                this.setState(mapStateToProps(stateFromStore));
            });
        }

        render() {
            return <Comp {...this.state} />;
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

We've also kept a default value for the mapStateToProps which simply returns what it receives. This is done so that if any Component doesnt pass mapStateToProps, it will receive the entire State. Otherwise our code would break.

What's pending?

Our Connect is still incomplete. Even though we're returning only the keys mentioned in the mapStateToProps, both Components get re-rendered even when only the other key gets updated. That is, when age increases, both Count and Age gets updated. And vice versa. How do we fix this?

Every time the store updates it's state, and Connect receives it via the callback, we first give it to mapStateToProps to get the needed state object for that particular Component. Then that newState can be matched with the existing state keys to check if anything new has been added or modified. If not, we ignore the re-rendering. If yes, we update the state and the wrapped component re-renders.

Connect.js

import React from "react";
import { StoreContext } from "./Provider";

export default function Connect(Comp, mapStateToProps = (state) => state) {
    return class extends React.Component {
        static contextType = StoreContext;

        componentDidMount() {
            const store = this.context;
            const firstState = mapStateToProps(store.getState());
            this.setState(firstState);
            let stateChanged = false;
            store.subscribe((stateFromStore) => {
                const newState = mapStateToProps(stateFromStore);
                for (let key in newState) {
                    if (newState[key] != this.state[key]) {
                        stateChanged = true;
                        break;
                    }
                }
                stateChanged && this.setState(newState);
            });
        }

        render() {
            return <Comp {...this.state} />;
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

PS: I'm not sure if this comparison is accurate of efficient. And most probably the actual Connect is doing a better job. What I've done is just to get an idea of how it can be done.

In Connect.js, if you replace this line - const newState = mapStateToProps(stateFromStore); with this const newState = mapStateToProps(stateFromStore, this.props);. Basically I've passed this.props to mapStateToProps function call.

And in App.js, where you pass mapStateToProps, add a second param ownProps, you can obtain the props that will be given to <Count /> and <Age /> in their corresponding mapStateToProps function definitions as ownProps.

<Count test={1} />

const Count = Connect(
    (props) => <div>Count: {props.count}</div>,
    (state, ownProps) => { //the prop 'test' would be available in ownProps
        return {
            count: state.count
        };
    }
);
Enter fullscreen mode Exit fullscreen mode

Here's a codesandbox if you want to play around with the above implementation without writing from scratch.

Share your thoughts and ping me if you have any questions or concerns.

Top comments (0)

🌚 Life is too short to browse without dark mode