DEV Community

Cover image for Full State Management in Vue 3 (without Vuex)
roggc
roggc

Posted on • Updated on

Full State Management in Vue 3 (without Vuex)

This post is a brother of Full State Management in React (without Redux).

The thing is we define a local state for each component, through the use of useReducer hook, then we mount up a tree of all the local states of all the components instantiated, and then make available that store through the use of useContext in React and provide-inject api in Vue.

Because in Vue we don't have a useReducer hook, we must do a simple equivalent one.

In this way we achieve a total control of the state in the tree.

The useReducer hook

Let's start by a simple equivalent of the useReducer hook in React, but for Vue. This will be the code:

import { reactive } from 'vue'

export const useReducer = (reducer, iState) => {
  const state = reactive(iState)
  const dispatch = (action) => {
    reducer(state, action)
  }
  return [state, dispatch]
}
Enter fullscreen mode Exit fullscreen mode

You see it's quite simple. When defining the initial state in a separate file for passing it to the useReducer function we must take care to define a function that returns each time (each invocation) a new object representing the initial state. If not, two instances of the same component will end up sharing the same state. Like this:

export const iState = () => ({
  counter: 0,
})
Enter fullscreen mode Exit fullscreen mode

Then, in the setup function of the composition API we do this:

  setup(props) {
    const [state, dispatch] = useReducer(reducer, iState())
Enter fullscreen mode Exit fullscreen mode

The reducer function

There is a difference in the definition of the reducer function respect to the one we do in React.

This is the reducer for this app:

export const reducer = (state, action) => {
  switch (action.type) {
    case INCREASE:
      state.counter++
      break
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see we mutate directly the object and don't create a new one because if we did that we will lose reactivity.

Passing information up to the component tree

The technique used to pass information from down to up is using a HOC to provide extra properties to the component, which are catched and infoRef.

catched is the callback passed to the child from where we want to get (catch) information, and infoRef is where we will store that information.

This is the HOC:

import { ref } from 'vue'

export default (C) => ({
  setup(props) {
    const infoRef1 = ref(null)
    const infoRef2 = ref(null)
    const infoRef3 = ref(null)
    const infoRef4 = ref(null)
    const catched1 = (info) => (infoRef1.value = info)
    const catched2 = (info) => (infoRef2.value = info)
    const catched3 = (info) => (infoRef3.value = info)
    const catched4 = (info) => (infoRef4.value = info)
    return () => {
      return (
        <C
          catched1={catched1}
          catched2={catched2}
          catched3={catched3}
          catched4={catched4}
          infoRef1={infoRef1}
          infoRef2={infoRef2}
          infoRef3={infoRef3}
          infoRef4={infoRef4}
          {...props}
        />
      )
    }
  },
})
Enter fullscreen mode Exit fullscreen mode

If you need more catched and infoRefs you can define them on this HOC as many as the maximum number of children a parent will have in the app.

As you can see we provide to the component with extra properties catched1, catched2, etc. The same for infoRef.

How do we use it?

Let's look at the use of it in the component definitions. First, let's stipulate the structure of the app, of the tree. We will have to component definitions, App and Counter. App will instantiate two Counters, while Counter does not have any child.

Let's look at the definition of the App component:

import { provide, reactive, ref, inject } from 'vue'
import Counter from '../Counter'
import styles from './index.module.css'
import withCatched from '../../hocs/withCatched'
import * as counterActions from '../Counter/actions'
import { iState, reducer } from './reducer'
import { useReducer } from '../../hooks/useReducer'

export default withCatched({
  props: ['catched1', 'infoRef1', 'catched2', 'infoRef2'],
  setup(props) {
    const [state, dispatch] = useReducer(reducer, iState)

    const name1 = 'counter1'
    const name2 = 'counter2'

    provide('store', {
      state,
      dispatch,
      [name1]: props.infoRef1,
      [name2]: props.infoRef2,
    })

    const store = inject('store')

    const clicked1 = () => {
      store[name1].value.dispatch(counterActions.increase())
    }

    const clicked2 = () => {
      store[name2].value.dispatch(counterActions.increase())
    }

    return () => {
      return (
        <div className={styles.some}>
          <Counter catched={props.catched1} name={name1} />
          <Counter catched={props.catched2} name={name2} />
          {store[name1].value && store[name1].value.state.counter}
          {store[name2].value && store[name2].value.state.counter}
          <button onClick={clicked1}>increase1</button>
          <button onClick={clicked2}>increase2</button>
        </div>
      )
    }
  },
})
Enter fullscreen mode Exit fullscreen mode

You can see how we use named components, that's it, we pass a property name to each instance of Counter in the App component.

Now, let's look at the definition of the Counter component:

import { onMounted, reactive, ref, inject, onUpdated } from 'vue'
import styles from './index.module.css'
import { useReducer } from '../../hooks/useReducer'
import { reducer, iState } from './reducer'

export default {
  props: ['catched', 'name'],
  setup(props) {
    const [state, dispatch] = useReducer(reducer, iState())

    onMounted(() => {
      props.catched.bind(null, { state, dispatch })()
    })

    const store = inject('store')

    return () => {
      return (
        <div class={styles.general}>
          {store[props.name].value && store[props.name].value.state.counter}
        </div>
      )
    }
  },
}
Enter fullscreen mode Exit fullscreen mode

Pay attention to this:

    onMounted(() => {
      props.catched.bind(null, { state, dispatch })()
    })
Enter fullscreen mode Exit fullscreen mode

This is how we uplift information to the parent component. In this case, we are sending up state and dispatch, but we can uplift any information we need to.

Conclusion

So that's it. This is how we can have perfect control of state and dispatch of all the components instantiated in the tree.

This is the final result:

Alt Text

As you can see the two counters are incremented individually.

Top comments (0)