DEV Community

Mansoor Omrani
Mansoor Omrani

Posted on

Making React Context easier to work with: A dynamic approach

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 }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

./hooks/useTheme.js

import { useContext } from "react";
import { ThemeContext } from "../contexts/Theme";

function useTheme() {
  const value = useContext(ThemeContext);

  return value;
}

export default useTheme;
Enter fullscreen mode Exit fullscreen mode

./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 }
Enter fullscreen mode Exit fullscreen mode

./hooks/useUser.js

import { useContext } from "react";
import { UserContext } from "../contexts/User";

function useUser() {
  const value = useContext(UserContext);

  return value;
}

export default useUser;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

./hooks/index.js

import useLang from './useLang'
import useTheme from './useTheme'
import useUser from './useUser'

export {
   useLang,
   useTheme,
   useUser
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>)
}
Enter fullscreen mode Exit fullscreen mode

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>)
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We only need to import hooks through ./contexts, not ./hooks.

./components/NavBar.jsx

import { useLang, useTheme } from '../contexts'

const NavBar = () => {
   ...
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 = () => {
   ...
}
Enter fullscreen mode Exit fullscreen mode

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.

AppContext

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');
...
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

./App.jsx

import appContext from './contexts'

const App = appContext.createApp(<>
      <h1>My App</h1>
      ...
    </>);

export default App;
Enter fullscreen mode Exit fullscreen mode

Not only will the App component be easier, but also we avoid context hell issue forever.

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read more →

Top comments (0)