react-slice-context

A a lightweight, performant, proxy-based state management library for React without any external dependencies. Come have a slice! 🍕

MIT License

Downloads
26
Stars
2
Committers
1

React Slice Context 🍕

react-slice-context is a lightweight, performant, proxy-based state management library for React, built on the concept of slices and leveraging the power of React hooks. It provides a simple and flexible way to manage state in your React applications.

Table of Contents

Features

No <Provider> Hassle

Say goodbye to the <Provider> wrapper! Simply initialize the context, and it's ready to be used from any part of your application!

Update States in A Breeze

No more action creators, actions, and reducers just to update a simple state. With React Slice Context, updating states is as straightforward as declaring functions that directly mutate the context state without worrying about reactivity. Less boilerplate, more productivity!

Effortless Nested State Updates

Forget about duplicating layers of objects just to modify a single property. Thanks to the proxy-based implementation, updating objects and arrays becomes a breeze. Plus, there's no need for Immer in React Slice Context!

No Context Loss

In contrast to the native React context, the context value in React Slice Context can be set up at the same level as your main application. This flexibility enables the context value to be accessed across multiple renderers within a single application!

Demo and Examples

Requirements

To use this library, make sure your react and react-dom versions are both 16.8.4 or later, as hooks were introduced in React 16.8.

Installation

Install react-slice-context in your project using any package manager of your choice; for example:

npm install react-slice-context

Getting Started

  1. Import the createSliceContext function from react-slice-context:

    import { createSliceContext } from 'react-slice-context'
    
  2. Create a slice context with an initial state and a dispatcher:

    import { createSliceContext } from 'react-slice-context'
    
    const pizzaContext = createSliceContext({
      state: () => {
        // Return your initial state here.
        return {
          price: 10,
          flavor: 'Pepperoni',
        }
      },
      dispatch: (pizza) => {
        // Define the functions to update the state of this slice context here.
        // You can mutate the state directly without any concerns!
        return {
          incrementPrice: () => {
            pizza.price++
          },
          setFlavor: (flavor: string) => {
            pizza.flavor = flavor
          },
        }
      },
    })
    
  3. Export the useContext and dispatch from the slice context. It is recommended to use destructing assignment syntax to rename them so that it's more convenient when you have multiple contexts.

    // Either export and rename them after declaration.
    const pizzaContext = createSliceContext({ ... })
    export const { useContext: usePizzaContext, dispatch: pizzaDispatch } = pizzaContext
    
    // ...or do it all at once.
    export const {
      useContext: usePizzaContext,
      dispatch: pizzaDispatch,
    } = createSliceContext({
      ...
    })
    
  4. Use the exported usePizzaContext (formerly useContext) and pizzaDispatch (formerly dispatch) to access the state and dispatcher within your components:

    import { usePizzaContext, pizzaDispatch } from './pizza-context'
    
    const MyComponent = () => {
      // This will cause the component to re-render whenever there's a change in `pizzaContext`.
      const pizza = usePizzaContext()
    
      const { incrementPrice } = pizzaDispatch
    
      return (
        <div>
          <h2>Pizza price: {pizza.price}</h2>
          <h2>Pizza flavor: {pizza.flavor}</h2>
          <button onClick={incrementPrice}>Increment Price</button>
        </div>
      )
    }
    

API

  1. createSliceContext(options)

    Creates a slice context with the specified options.

    Options

    Property Type Required Description Default Value
    state function A function that returns the initial state for the slice context.
    dispatch function A function that returns the dispatcher (a set of dispatch functions) for the slice context. The functions declared in the dispatcher are the only ones allowed to change the context state. For more information, please refer to the Dispatch section below.
    plugins Array No An array of plugins that enables you to inject custom hooks into the context's lifecycle. Please refer to the Plugins section below. undefined

    Return Value

    It returns a slice context with the following properties:

    Property Type Description
    useContext(selector) function useContext(selector) is a hook to retrieve the state of the associated context; it can only be used within the body of a React functiona component. By default, when the optional selector argument is not provided, useContext() returns the entire state, which will cause the component to re-render whenever there's a change in the context. If your component only care about specific context properties, using something like const state = useMyContext() may not deliver optimal performance. Check out the Optimization section below for further insights.
    dispatch object The dispatcher for the slice context. It works both inside and outside of components. See the Dispatch section below for more information.
    getState() function Returns a read-only state in the slice context. This is useful for getting context state outside of components.

Optimization

By default, useContext() returns the entire state, which will cause the component to re-render whenever there's a change in the context. Consider the pizzaContext as shown below:

const { useContext: usePizzaContext } = createSliceContext({
  state: () => ({
    price: 10,
    flavor: 'Pepperoni',
  }),
})

In the following component, only the price from pizzaContext is relevant, but the change of pizzaContext.flavor will still cause this component to re-render:

const MyComponent = () => {
  // Bad
  const pizza = usePizzaContext()

  // `pizza.flavor` is not being used anywhere in this component.
  // However, the change of `pizza.flavor` will still cause this
  // component to re-render!

  return <div>Pizza price: {pizza.price}</div>
}

To address this and optimize performance, we can utilize the optional selector argument in useContext(selector). For example:

const MyComponent = () => {
  // Good!
  const pizzaPrice = usePizzaContext((state) => state.price)

  // `pizza.flavor` doesn't affect this component anymore.

  return <div>Pizza price: {pizza.price}</div>
}

Think of useContext(selector) as useState() with built-in awareness of when to update itself. The selector function receives the current context value and expects a return value. If the price in pizzaContext changes, the pizzaPrice here will update, leading to a re-render of this component.

Noted that selector executes whenever there's a change in the context value. This means if the return value of selector is a new non-primitve value (e.g., an object or array), the component will still re-render whenever there's a change in the context value, even if related values haven't changed. For example:

// Context
const { useContext: usePizzaContext } = createSliceContext({
  state: () => ({
    price: 10,
    flavor: 'Pepperoni',
    frozen: true,
  }),
})

// Component
const MyComponent = () => {
  // Bad
  const priceAndFlavor = usePizzaContext((state) => ({
    price: state.price,
    flavor: state.flavor,
  }))

  // `pizza.frozen` is not being used anywhere in this component.
  // However, the change of `pizza.frozen` will still cause this
  // component to re-render!

  return <div>...</div>
}

To mitigate this, separate priceAndFlavor into two distinct usePizzaContext(selector) calls:

// Context
const { useContext: usePizzaContext } = createSliceContext({
  state: () => ({
    price: 10,
    flavor: 'Pepperoni',
    frozen: true,
  }),
})

// Component
const MyComponent = () => {
  // Good!
  const price = usePizzaContext((state) => state.price)
  const flavor = usePizzaContext((state) => state.flavor)

  // `pizza.frozen` doesn't affect this component anymore.

  return <div>...</div>
}

This ensures that changes in pizzaContext.frozen do not cause unnecessary re-renders.

Dispatch

The dispatch object returned by createSliceContext(options) is a set of dispatch functions. Only the functions declared in the dispatcher are permitted to modify the context state.

States Outside of Dispatch Are Read-Only

The values returned by useContext(selector) and getState() are read-only. Attempting to update the context value without using the corresponding dispatch functions will trigger a warning in the console, and no changes will be applied to the context. Trying to execute code similar to the following example will result in a warning:

const MyComponent = () => {
  const pizza = usePizzaContext()

  const raisePrice = () => {
    // Invalid: this will generate a warning in the console,
    // and `pizza.price` will remain unchanged.
    pizza.price += 5
  }

  return <div>...</div>
}

Asynchronous Dispatch

Asynchronous dispatch functions are supported in React Slice Context. If your dispatch function involves any asynchronous operations, such as calling an API, make sure to use the async keyword to ensure that state are updated correctly within an asynchronous function. For example:

const context = createSliceContext({
  // ...
  dispatch: (state) => {
    // The `async` here is necessary!
    loadData: async () => {
      state.loading = true
      state.data = await callAPI()
      state.loading = false
    }
  },
})

Plugins

A plugin serves as an optional extension to the slice context, enabling you to inject custom hooks into the context's lifecycle. The plugin interface encompasses the following hooks (all hooks are optional!):

Name Description
onStateInit(state) Called when the context state is initialized. The provided state is read-only.
onChange(state) Called whenever there's a change in the context state. The provided state is read-only.

This feature is particularly useful when you need to persist the state somewhere upon a state change, such as in localStorage or a database. For example:

const myContext = createSliceContext({
  state: () => ({ ... }),
  dispatch: () => ({ ... })
  plugins: [
    {
      onChange: (state) => {
        localStorage.setItem('SOME_KEY', JSON.stringify(state))
      }
    },
    // ...other plugins
  ]
})

You can have multiple plugins within a slice context, and the hooks in these plugins are invoked in the order they are arranged within the plugins array.

Update Context Value Outside of Components

To update context value outside of components, you can use the functions declared in the dispatcher, just as you would when updating the context value inside components. For example:

// Context
const { dispatch: authDispatch } = createSliceContext({
  state: () => ({
    token: undefined,
  }),
  dispatch: (auth) => {
    setToken: (token: string) => {
      auth.token = token
    }
  },
})

// In some other non-component files
import { authDispatch } from './auth-context'

authDispatch.setToken('...')

Get Context Value Outside of Components

To get context value outside of components, you can simply utilize the getState() function provided by createSliceContext(options). For example:

// Context
const { getState: getAuthState } = createSliceContext({
  state: () => ({
    token: undefined,
  }),
})

// In some other non-component files
import { getAuthState } from './auth-context'

axios.interceptors.request.use((request) => {
  const { token } = getAuthState()
  request.headers.Authorization = `Bearer ${token}`
})

It's important to note that the value returned by getState() is read-only. As mentioned earlier, only functions declared in the dispatcher are permitted to modify the context state.

Common Mistakes

Please be aware that, due to the nature of JavaScript, primitive types won't behave as expected when used with destructuring assignment or when assigned to another variable. For example:

// Context
const { useContext: usePizzaContext } = createSliceContext({
  state: () => ({
    price: 10,
  }),
})

// Component
const MyComponent = () => {
  // Incorrect: `price` will not be reactive.
  const { price } = usePizzaContext()
  // Incorrect: `price` will not be reactive.
  const { price } = usePizzaContext((state) => state)
  // Incorrect: `price` will not be reactive.
  const price = usePizzaContext().price

  // Correct!
  const price = usePizzaContext((state) => state.price)

  return <div>...</div>
}