DEV Community

Jacob Chang
Jacob Chang

Posted on

Combine Lens & Context & Hooks to manage global state in React

Complete Code: issue5

Recently I was working on a personal projects with React. Since it's not a big projects, I decide to get rid of any state management library.

For component level, it's simple, we can just use Hooks to solve this, like useState, useReducer. But how to manage a global state in a React app ? Luckily, we have Context. so we can just manage state in top level component with useReducer, and leverage Context to pass those data to child component.

export interface IProfile {
  name: string;
  age: number;
  country: string;
}

export interface IUser {
  id: number;
  profile: IProfile
}

export interface IStore {
  user: IUser;
}

export const initialStore: IStore = {
  user: {
    id: 0,
    profile: {
      name: 'initial user name',
      age: 0,
      country: 'anywhere'
    }
  },
}

export type Action = (store: IStore) => IStore; // explain later

export interface IStoreContext {
  store: IStore,
  dispatch: (action: Action) => void;
}

export const initialContext: IStoreContext = {
  store: initialStore,
  dispatch: () => {
    throw new Error('this initial function should never be called')
  }
}

export const StoreContext = React.createContext<IStoreContext>(initialContext);

// explain later
export function reducer(store: IStore, setter: Action) {
  return setter(store);
}

import React from 'React';

import { reducer, initialStore, StoreContext } from './Context/StoreContext';

export function App() {
    <StoreContext.Provider value={{ store, dispatch }}>
      <div className="App">
        <header className="App-header">
          <StoreContext.Consumer>
            {({ store, dispatch }) => {
              return (
                <div>
                  <p>{JSON.stringify(store)}</p>
                </div>
              )
            }}
          </StoreContext.Consumer>
        </header>
      </div>
    </StoreContext.Provider>
}

It looks good so far, we have solve the global state management with Context & Hooks. but there's several problem bother me a lot. Normally, when we use reducer, we intend to define a lot of action and update store use a big switch statement.

export interface IUpdateUserName {
  kind: 'updateUserName'
  payload: {
    username: string
  }
}

type Action = UpdateUserName

export function reducer(store: IStore, action: Action) {
  switch(action.kind) {
    case 'updateUserName':
        return {
            ...store,
            user: {
                ...store.user,
               profile: {
                   ...store.user.profile,
                   username: action.payload.username
               }
            }
        };
    break;
  }
}


// Then we can dispatch action in component like this
dispatch({
  action: 'updateUserName',
  payload: {
    username: 'new user name'
  }
})

Consider the code above, it's really not a joy to update nested property in state, even though spread operator has save us a lot of work and type checking can make sure we don't update the wrong field, but can we make it better ?

Then I really why not use Lens and just dispatch a setter ? This is why at first, the action type is defined as

export type Action = (store: IStore) => IStore

If you don't familiar with Lens, you can consider it as a combination of getter and setter function. Getter is used to read value and Setter is used to update value. Here's a simple version of Lens

export interface ILens<A, B> {
    get: (a: A) => B;
    set: (b: B) => (a: A) => A;
}

// construct a Lens from property name
// get will be a function to read property object object
// set will be a function to set value of object
export function lens<A, P extends keyof A>(prop: P): ILens<A, A[P]> {
    return {
        get: (a: A) => {
            return a[prop];
        },
        set: (propValue: A[P]) => {
            return (a: A) => {
                return {
                    ...a,
                    [prop]: propValue,
                }
            }
        }
    }
}

// compose can combine a fuction to form another Lens
//  it's useful when we want to read/write nested value
export const compose = <A, B, C>(lensAB: ILens<A, B>) => {
    return (lensBC: ILens<B, C>): ILens<A, C> => {
        return {
            get: (a: A) => {
                return lensBC.get(lensAB.get(a))
            },
            set: (c: C) => {
                return (a: A) => {
                    const b = lensAB.get(a);
                    const updatedB = lensBC.set(c)(b)
                    return lensAB.set(updatedB)(a)
                }
            }
        }
    }
}

Next, we can define some lens for IStore property and see how to dispatch lens to update username

export const storeUserLens = lens<IStore, 'user'>('user');
export const userProfileLens = lens<IUser, 'profile'>('profile')
export const profileNameLens = lens<IProfile, 'name'>('name');

export const storeUserProfileLens =
  compose<IStore, IUser, IProfile>(storeUserLens)(userProfileLens)

export const storeUserProfileNameLens =
  compose<IStore, IProfile, string>(storeUserProfileLens)(profileNameLens)



// In component, we can use getter to retrive nested value and dispatch a setter to update user name
          <StoreContext.Consumer>
            {({ store, dispatch }) => {
              return (
                <div>
                  <p>{storeUserProfileNameLens.get(store)}</p>
                  <button type="button" onClick={evt => {
                    dispatch(storeUserProfileNameLens.set('new user name'));
                  }}>Update name</button>
                </div>
              )
            }}
          </StoreContext.Consumer>

Note, this lens definition is not very well formed, it you want to use Lens in your project, you can try monocle-ts

Top comments (0)