Disclaimer: This solution is best suited for small scale projects, and its main motive is to explore the new APIs React provides rather than trying to replace any traditional solutions.
So when was the last time you tried managing application state, without Redux? As far as I can remember, for me, it was probably during the time I had started learning and playing around with React. Although I had known about Redux at the time when I did try not using it, I just wanted to do it because I didn’t want to add three dependencies to my react application just for managing a tiny application state. Of course, when we talk about using redux, we are also going to use react-redux
and middleware too!
I did it in two different ways ( which most of us, might have tried at some point as well )
-
Using
localStorage
and custom listeners. -
Using the Context API.
But in this article, we’re not going to discuss that. We’re going to see another way of managing the state, which is relatively new — using Hooks.
So, let’s set up a react project and add a root component, like so :
import React from "react";
import "./App.scss";
import ChildComponent from "./ChildComponent";
const App = () => {
return (
<>
<div className="app">
<h2>Welcome to React!</h2>
</div>
<ChildComponent />
</>
);
};
export default App;
And our ChildComponent.js
as,
import React from 'react';
const ChildComponent = () => {
return (
<div>
<p>This is the child component</p>
</div>
);
}
export default ChildComponent;
First, let us break down the complete flow and decide what we need :
-
A state, of course
-
Update our state.
-
Sync the data between the state and our components wherever required.
-
Do all of this while keeping the code clean. ( Very important )
Set up store/state
Let’s set up our state first. For this, I’m going to use the useReducer
hook. For those who are not familiar with the useReducer
hook — it is similar to the basic useState
hook but more suited for this case as it provides a dispatch
method, the previous state while computing and updating the state. It provides us a way that is similar to Redux’s reducer and action flow. Let’s set up our useGlobalState
hook, which helps us initialize our state and provide us a dispatch for updating it as well.
So our redux.js
looks like this :
import React from "react";
export const useGlobalState = (initialState = {}, reducer) => {
let init;
let appState = {};
if (typeof initialState === "function") {
init = initialState;
} else {
appState = initialState;
}
const [state, dispatch] = React.useReducer(
reducer,
appState,
init
);
return {
...state, dispatch
};
};
So what’s going on here? Our custom hook here takes two arguments — initialState
for setting an initial state to our app and reducer
is our reducer function for updating state depending on the actions.
Our reducer.js
might look like this :
export default (state, action) => {
switch (action.type) {
case 'increment':
return {
...state,
count: state.count + 1,
};
default:
return state;
}
}
Use reducer and global state
With those two arguments, we can initialize our reducer and state as :
const [state, dispatch] = React.useReducer(reducer, initialState);
Since our initialState
might, in some cases, require some computation and may not be just a static value — we are going to use the third argument of useReducer
to initialize our state from a function just in case we need to. So now we can initialize our state in two ways :
const globalState = useGlobalState(intialState, reducer);
// OR
const globalState = useGlobalState(() => {
// Do stuff here.
return state;
}, reducer);
But where do we initialize our state? Let’s add this to our App.js
file and use the classic example of making a simple counter.
import React from "react";
import { useGlobalState } from "./redux";
import reducer from "./reducer";
import ChildComponent from "./ChildComponent";
import "./App.scss";
const App = () => {
const globalState = useGlobalState({ count: 0 }, reducer);
return (
<>
<div className="app">
<h2>Welcome to React!</h2>
<h2>{globalState.count}</h2>
<button type="button" onClick={() => globalState.dispatch({ type: "increment" })}>
Click me to plus one!
</button>
</div>
<ChildComponent />
</>
);
};
export default App;
This gives us something like this :
But still, we can’t use the state inside our ChildComponent
since it has no idea of this state. We’re going to use the createContext
API for that.
Let’s update our redux.js
to give us a way to pass the state to our child(ren) component(s), like so : :
import React from "react";
export const context = React.createContext();
export const useGlobalState = (initialState = {}, reducer) => {
let init;
let appState = {};
if (typeof initialState === "function") {
init = initialState;
} else {
appState = initialState;
}
const [state, dispatch] = React.useReducer(
reducer,
appState,
init
);
return { ...state, dispatch };
};
export const Provider = ({ children, initialValue }) => {
return (
<context.Provider value={{ ...initialValue }}>
{children}
</context.Provider>
);
};
I guess you can see where this is going. We are going to use the Provider component in our root App.js
and wrap our component with it.
Additionally, we’ll pass an initial state as the value for our ‘provider.’ This makes the values available in the DOM tree. But then you might wonder — we need to wrap all our children who are going to use the state with context.Consumer
, don’t we? Well, no.
Here’s were our React.useContext
hook comes into play along with a little HOC trick. And we’re going to name it connect
, so it looks similar to redux! Also, it can be easier to understand if we can visualize it in the ‘redux way.’ But first, let’s check if our current setup works.
Update the App.js
file to this :
import React from "react";
import { useGlobalState, Provider } from "./redux";
import reducer from "./reducer";
import ChildComponent from "./ChildComponent";
import "./App.scss";
const App = () => {
const globalState = useGlobalState({ count: 0 }, reducer);
return (
<Provider initialValue={globalState}>
<div className="app">
<h2>Welcome to React!</h2>
<h2>{globalState.count}</h2>
<button type="button" onClick={() => globalState.dispatch({ type: "increment" })}>
Click me to plus one!
</button>
</div>
<ChildComponent />
</Provider>
);
};
export default App;
And our ChildComponent.js
like this :
import React from 'react'
import { context } from './redux';
const ChildComponent = () => {
const state = React.useContext(context);
return (
<div>
<p>This is the child component.</p>
<p>{state.count}</p>
</div>
);
}
export default ChildComponent;
So what does useContext
hook do? Well, it’s similar to using context.Consumer
tag which allowed us to access context value and subscribe to its changes. With useContext
hook, we no longer use the context.Consumer
in our component. We pass the context object to it, which then returns the value from the current context. Whenever the context data changes, the component is re-rendered with the new values.
Let’s see if this works.
Great! But there’s one thing. Now we need to call useContext
in every component! Let’s get rid of this. We’re going to write a small HOC which exposes an API similar to the connect
HOC from react-redux
.
Now, our redux.js
should look like this :
import React from "react";
const context = React.createContext();
export const useGlobalState = (initialState = {}, reducer) => {
let init;
let appState = {};
if (typeof initialState === "function") {
init = initialState;
} else {
appState = initialState;
}
const [state, dispatch] = React.useReducer(
reducer,
appState,
init
);
return { ...state, dispatch };
};
export const connect = (mapState, mapDispatch) => {
const componentWithNewProps = (ReactComponent) => {
const componentWithContext = (thisprops) => {
const { dispatch, ...rest } = React.useContext(context);
const mappedProps = mapState ? mapState(rest) : {};
const mappedDispatch = mapDispatch ? mapDispatch(dispatch) : {};
const props = {
...mappedProps,
...mappedDispatch,
};
return <ReactComponent {...props} {...thisprops} />
}
return componentWithContext
}
return componentWithNewProps
}
export const Provider = ({ children, initialValue }) => {
return (
<context.Provider value={{ ...initialValue }}>
{children}
</context.Provider>
);
};
Note: As you can see, we are just spreading the props over the component here. The ideal way is to use the
hoist-non-react-statics
package to copy all non-react static methods to the new component. Please use that way since it is better than just passing the props. A complete explanation is in the React Documentation for Higher-Order Components.
The connect
HOC here takes our component and uses the context to get all the props that are required by the component as defined in the mapStateToProps
function in the connect
call. We can update our ChildComponent.js
now, to something like this :
import React from 'react';
import { connect } from './redux';
const ChildComponent = ({ count, updateCounter }) => {
return (
<div>
<p>This is the child component</p>
<p>{count}</p>
<button type='button' onClick={updateCounter}>Click me, I can plus one too!</button>
</div>
);
}
const mapState = ({ count }) => ({
count
});
const mapDispatch = (dispatch) => ({
updateCounter: () => dispatch({ type: 'increment' })
});
export default connect(mapState, mapDispatch)(ChildComponent);
Let’s check if this works.
In case you’re wondering, you can have different counters for both of them, and it’ll work just fine! You need to initialize the state with both the counters, dispatch actions from their respective buttons, and use the respective values from the state to display. Like so :
// In App.js, initialize counters like this
const globalState = useGlobalState({ count: 0, anothercount: 1 }, reducer);
/**
* In ChildComponent.js, update the `mapState` and `mapDispatch` methods
* to get and update `anothercount` value from state.
*/
const mapState = ({ anothercount }) => ({ // Get the `anothercount` value from state.
count: anothercount,
});
const mapDispatch = (dispatch) => ({
// Update the dispatch to trigger `countincrement` action.
updateCounter: () => dispatch({ type: 'countincrement' })
});
export default connect(mapState, mapDispatch)(ChildComponent);
/**
* Finally, update our reducer to handle `countincrement` action,
* which updates the `anothercount` value in our state.
*/
export default (state, action) => {
switch (action.type) {
case 'increment':
return {
...state,
count: state.count + 1,
};
case 'countincrement':
return {
...state,
anothercount: state.anothercount + 1,
};
default:
return state;
}
}
Oh, and one more thing! Don’t forget to wrap your components with React.memo
if they’re not connected to the state. This prevents unnecessary re-renders when the state updates.
And we’re done implementing a small redux-like application state management in our react application! All within just 40 lines of code! ✨
You can check out the complete example in this Github repository. Please leave a star on the repository or comment here if you liked this article!
If you have any suggestions or ideas, you can also contact me through my social media profiles.
Thank you for reading! 😄
Happy hacking! Cheers! 🎉