Abstract
Context is a useful and common way to provide a global state in a react app, like current language, theme or user. In this article, we will introduce a helper class that eases creating and working with contexts in React applications.
TL;DR
Go to the bottom of the page. :)
Story
Suppose we have a multi-language and multi-theme app in which we plan to use context to store current app's language and theme. Also, we use a user context to store currently logged in user's information.
Common Approach
Based on best practices, we are better to create a ./contexts
folder and put our contexts there.
Here is the code for our language context.
./contexts/Lang.jsx
import React, { createContext, useState } from "react";
const LangContext = createContext([, (x) => x]);
const Lang = ({ lang, children }) => {
const [value, setValue] = useState(lang);
return (
<LangContext.Provider value={[value, setValue]}>
{children}
</LangContext.Provider>
);
}
export { LangContext, Lang }
As it is seen, we wrapped our context in a Lang
component and used a state in order to store current language. We then used the state and its setter function as the context's value.
Next, we are also better to create a ./hooks
folder and put our hooks there.
./hooks/useLang.js
import { useContext } from "react";
import { LangContext } from "../contexts/Lang";
function useLang() {
const value = useContext(LangContext);
return value;
}
export default useLang;
The same can be done for Theme
and User
contexts and their hooks.
./contexts/Theme.jsx
import React, { createContext, useState } from "react";
const ThemeContext = createContext([, (x) => x]);
const Theme = ({ theme, children }) => {
const [value, setValue] = useState(theme);
return (
<ThemeContext.Provider value={[value, setValue]}>
{children}
</ThemeContext.Provider>
);
}
export { ThemeContext, Theme }
./hooks/useTheme.js
import { useContext } from "react";
import { ThemeContext } from "../contexts/Theme";
function useTheme() {
const value = useContext(ThemeContext);
return value;
}
export default useTheme;
./contexts/User.jsx
import React, { createContext, useState } from "react";
const UserContext = createContext([, (x) => x]);
const User = ({ user, children }) => {
const [value, setValue] = useState(user);
return (
<UserContext.Provider value={[value, setValue]}>
{children}
</UserContext.Provider>
);
}
export { UserContext, User }
./hooks/useUser.js
import { useContext } from "react";
import { UserContext } from "../contexts/User";
function useUser() {
const value = useContext(UserContext);
return value;
}
export default useUser;
We also create index.js
files inside ./contexts
and ./hooks
folders to simlifies our imports.
./contexts/index.js
import { Lang, LangContext } from './Lang'
import { Theme, ThemeContext } from './Theme'
import { User, UserContext } from './User'
export {
Lang, LangContext,
Theme, ThemeContext,
User, UserContext
}
./hooks/index.js
import useLang from './useLang'
import useTheme from './useTheme'
import useUser from './useUser'
export {
useLang,
useTheme,
useUser
}
Here is a simple main application component that uses the contexts:
import { Lang, Theme, User } from './contexts';
const App = () => <>
<Lang lang={'en'}>
<Theme theme={'blue'}>
<User user={null}>
<h1>My App</h1>
...
</User>
</Theme>
</Lang>
</>
export default App
Using contexts' hooks
NavBar
Using the contexts is easy. Here is a simple bootstrap navbar with a language and theme drop down.
./components/navbar.jsx
import { useLang, useTheme } from '../hooks';
const langs = ['en', 'de', 'fr', 'es'];
const themes = ['red', 'green', 'blue', 'dark', 'light'];
const NavBar = () => {
const [lang, setLang] = useLang();
const [theme, setTheme] = useLang();
return (<nav class="navbar navbar-expand-lg navbar-light">
<div className="collapse navbar-collapse">
<ul className="navbar-nav">
<li className="nav-item active">
<a className="nav-link" href="/">Home <span class="sr-only">(current)</span></a>
</li>
<li className="nav-item dropdown">
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Language
</a>
<div className="dropdown-menu" aria-labelledby="navbarDropdown">
{langs.map(la => <a key={la} class={`dropdown-item ${la == lang ? 'active': ''}`} onClick={() => setLang(la)}>{la}</a>)
</div>
</li>
<li className="nav-item dropdown">
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Theme
</a>
<div className="dropdown-menu" aria-labelledby="navbarDropdown">
{themes.map(th => <a key={th} class={`dropdown-item ${th == theme ? 'active': ''}`} onClick={() => setTheme(theme)}>{theme}</a>)
</div>
</li>
</ul>
</div>
</nav>)
}
Login
User
context is used to store currently logged-in user and provide it for the entire app.
./components/Login.jsx
import { useUser } from '../hooks';
const Login = () => {
const [user, setUser] = useUser();
const [username, setUserName] = useState()
const [password, setPassword] = useState()
const handleLoginClicked = () => {
// check user credentials here
if (username == 'admin' && password == '123') {
setUser({ username });
} else {
alert('Login failed');
}
}
return (<form>
<div className="form-group">
<label for="username">Username</label>
<input type="text" className="form-control" placeholder="Enter username" value={username} onChange={e => setUsername(e.target.value)}>
</div>
<div className="form-group">
<label for="password">Password</label>
<input type="password" className="form-control" placeholder="Enter password" value={password} onChange={e => setPassword(e.target.value)}>
</div>
<button type="submit" className="btn btn-primary" onClick={handleLoginClicked}>Submit</button>
</form>)
}
Discussion
What we saw is the common approach in using contexts. It is straightforward and removes the need to use a third-party state management library like Redux
. We all know that.
However, looking back at the code, there are two issues with it.
First, contexts' code seems redundant. Moreover, we need to repeat a similar code in order to add a new context.
Second, adding more contexts, leads to a code-smell known as context hell.
There are solutions to avoid context hell. Again, we all know that.
However, is it possible to enhance our code in a way to resolve the first issue?
If our contexts follow a similar structure, the answer is yes.
Here, all of our contexts use a single internal state and publicize it together with the setter function through context's value.
Solution: createContext()
helper
We can write a global helper function that is able to create contexts dynamically, once and for all without the need to define them manually.
We name it createContext
and put it in a util
folder in our app.
./util/createContext.jsx
import React, { createContext, useState, useContext } from "react";
function _createContext(name, state) {
name = name[0].toUpperCase() + name.substr(1);
const Context = createContext([state, () => state]);
const Provider = (props) => {
const { [name]: _state, children } = props;
const [value, setValue] = useState(_state || state);
return (
<Context.Provider value={[value, setValue]}>
{children}
</Context.Provider>
);
}
function use() {
const value = useContext(Context);
return value;
}
return { [name + 'Context']: Context, [name]: Provider, ['use' + name]: use };
}
export default _createContext;
As it is seen, our helper also creates a hook. So, we don't need to write hooks manually.
Improved Approach-1
Now, creating our Lang
, Theme
and User
contexts will be a little easier.
./contexts/index.js
import createContext from '../util/createContext'
const { Lang, LangContext, useLang } = createContext('Lang')
const { Theme, ThemeContext, useTheme } = createContext('Theme')
const { User, UserContext, useUser } = createContext('User')
export {
Lang, LangContext, useLang,
Theme, ThemeContext, useTheme,
User, UserContext, useUser
}
There is no need to really expose contexts themselves (LangContext
, ThemeContext
, UserContext
). We can work with them through their hooks. Nevertheless, we expose them, so that one may want to use contexts in a class component.
The application code is the same and no change is required:
import { Lang, Theme, User } from './contexts';
const App = () => <>
<Lang lang={'en'}>
<Theme theme={'blue'}>
<User user={null}>
<h1>My App</h1>
...
</User>
</Theme>
</Lang>
</>
export default App
We only need to import hooks through ./contexts
, not ./hooks
.
./components/NavBar.jsx
import { useLang, useTheme } from '../contexts'
const NavBar = () => {
...
}
More Improvement: AppContext
class
Does the improvement end? No. A second improvement could also be used.
We can create a helper class on top of our createContext
and resolve context requirement of our application and any other future application entirely.
./util/AppContext.jsx
import createContext from './createContext';
class AppContext {
constructor(...names) {
this.createContexts(...names)
}
createContexts(...names) {
this.contexts = {}
this.hooks = {}
this.providers = {}
this.elements = []
for (let name of names) {
const ctx = createContext(name);
const Provider = ctx[name];
const context = ctx[name + 'Context'];
const hook = ctx['use' + name];
this.elements.push(<Provider />);
this.providers[name] = Provider;
this.contexts[name] = context;
this.hooks['use' + name] = hook
}
}
createApp(child) {
const _elements = [...this.elements]
if (child) {
_elements.push(child);
}
const root = _elements.reduceRight((prev, ctx) => React.cloneElement(ctx, {}, prev))
return () => <>{root}</>;
}
}
export default AppContext;
This class, provides a createApp()
method that merges all contexts together and avoids context hell.
Improved Approach-2
First, let's change our context creation.
./contexts/index.js
import AppContext from '../util/AppContext'
const appContext = new AppContext('Lang', 'Theme', 'User');
const { useLang, useTheme, useUser } = appContext.hooks;
export default appContext
export { useLang, useTheme, useUser }
Next, using createApp()
we don't need to manually import and use contexts.
Thus, our App
component will be briefed into the following code:
./App.jsx
import appContext from './contexts'
const App = appContext.createApp(<>
<h1>My App</h1>
...
</>);
export default App;
That's all.
The createApp()
method receives a child component and puts it inside the last context. That's where app's body or content go.
Lastly, in order to work with a context's hook, we should import it through our appContext.js
.
./components/NavBar.jsx
import { useLang, useTheme } from '../contexts'
const NavBar = () => {
...
}
Conclusion
As we saw, using AppContext
helper class makes application code simpler regarding context.
In reality, there is no need to create a contexts
folder at all. We can create a appContext.js
, perhaps at the root of our app, and write our context creation code there.
That file forms our application's context. That's why we name it appContext
. A file that holds the app's context.
I hope readers find this approach useful. Feel free to post your comments and thoughts.
P.S.
Final AppContext
helper class is put at the following github gist.
It also supports inital states for contexts. We just need to pass the context and its inital state as an array to AppContext
constructor.
./appContext.js
const appContext = new AppContext(['Lang', 'en'], ['Theme', 'light'], 'User');
...
Furthur thoughts:
If ES6 had supported dynamic export, we could write our context in a more briefed form (not needing to destructure and export the hooks explicitly and manually):
const appContext = new AppContext('Lang', 'Theme', 'User');
export default appContext
export { ...appContext.hooks }
It seems, the reason behind ECMASCript's not to support dynamic export was to provide better tree-shaking.
If we could switch to commonjs, we could make appContext
a little simpler.
const appContext = new AppContext('Lang', 'Theme', 'User');
exports = { appContext, ...appContext.hooks }
However, I couldn't put this approach into practice, due to errors happened after that. I will be happy to know if anyone finds a solution for that.
TL;DR
Using the helper AppContext
, it is easier to create and work with contexts in react applications.
Instead of explicitly and manually creating separate contexts and hooks, we just need to create a single appContext.js
at the root of our project, and create our contexts there.
./appContext.js
import AppContext from '..../AppContext'
const appContext = new AppContext('Lang', 'Theme', 'User');
const { useLang, useTheme, useUser } = appContext.hooks;
export default appContext
export { useLang, useTheme, useUser }
./App.jsx
import appContext from './contexts'
const App = appContext.createApp(<>
<h1>My App</h1>
...
</>);
export default App;
Not only will the App component be easier, but also we avoid context hell issue forever.
Top comments (0)