#vuejs #vuecember2020 #vue3

State Management in Vue 3

State management is one of the topics that keep me thinking since announcing Vue States. Which tools and patterns we choose for state management greatly affects the design of our applications.

Current systems like Vuex have their benefits:

[Vuex is] well-integrated with Vue Devtools, offers a lot of useful features like plugins or subscriptions, has well-established best practices and is known by almost every Vue developer (by Filip Rakowski on VueSchool)

However, they have also a whole bunch of drawbacks — the biggest of all being a huge increase in complexity. They mostly support only global state, which in many cases does not fit whats needed and they make things like testing and refactoring much more difficult.

🎙 Current developments

The introduction of the Composition API revived the discussion about state management patterns and tools:

I tried new approaches as well with an experimental vue-state-composer, but ultimately, no solution looked promising enough to provide both the flexibility and the tooling I search for .

🐣 Imagening

After lots of thoughts 🤔, tea 🍵 and experiments 🧪 I believe the Composition API does allow us to build solutions with greater flexibility and tooling — reducing the drawbacks we currently face.

Such a solution should…

  • encapsulate state and logic in plain composition functions.
  • support not only global state, but local state and the context-provider pattern as well.
  • support fine-grained client-side hydration by third-party composition functions like NuxtJS’ ssrRef.
  • provide a Vue.js devtools integration of comparable or improved quality.
  • restrict the flexibility of composition functions only as much as is required for devtools to work reliably and to provide the necessary guidance.

This approach will look similar to stores written with Vuex 5 and the Composition API.
However, in my opinion, the solution is not a single, self-contained state management system for every conceivable use case.
Instead, I advocate developing a set of independent tools & patterns that collectively enable state management using the Composition API in a wide variety of use cases.

The biggest concerns I continue to have about using the Composition API for state management deal with how the necessary insights can be gathered for the Vue.js Devtools.
Vuex's previous API and also the Options API made it easy to install hooks and watchers. Gaining the same information using the Composition API is much more challenging.

In case you are not familiar with the Composition API I recommend taking a look at the Composition API RFC first. If you haven’t done so far I also suggest reading about state management with plain composition functions and to checkout pinia as well as Vuex 5.

Basic Example

All further code samples will be based on this useCounter example:

export function useCounter() %7B
  const state = reactive(%7B
    count: 0,
  %7D)

  function increment() %7B
    state.count++
  %7D

  return %7B
    state,
    increment
  %7D
%7D

Global and local state

As stated before, a solution should support global state, local state and the context-provider pattern. There are already lots of examples how to implement all three with the Composition API and I will just present some options in addition:

The context-provider pattern is pretty straightforward with Vue 3. However, it could be further simplified by generating the necessary composition functions automatically:

export const %7B
  provider: useCounterProvider,
  injector: useCounterContext 
%7D = asProvideInject(useCounter)

Global state must be isolated in case of server-side rendering, but that separation must not be the concern of a state management system. We can rely on NuxtJS’ context for that. Custom server-side rendering applications can implement a similar mechanism.

// NuxtJS' API will probably change with version 3
export default (_context, inject) => %7B
  inject('counter', useCounter())
%7D

I also want to point out that this is only necessary if the state is actually mutated on the server. Otherwise, it could be shared between requests and it does not need to be hydrated. This is usually the case for all session-based data like user, cart and authentication:

// in pure single-page-applications
export const counter = useCounter()

// in server-side rendering applications
// where state is not mutated server-side (i.e. authentication)
// one could skip request isolation
export const counter = process.server
  // withLockedState would raise an Exception if state was changed
  // to ensure requests are not leaking
  ? withLockedState(useCounter)()
  : useCounter()

Client-side hydration

For hydration after server-side rendering we can rely on NuxtJS' ssrRef:

export function useCounter() %7B
  const state = reactive(%7B
     count: ssrRef(0),
  %7D)
  // ... 
%7D

For custom server-side rendering applications, equivalent composition functions can be developed.

Note that this requires more knowledge, but also gives more fine-grained control over how much payload is sent to the client. Stores containing user- or authentication data are usually mutated only in the browser and don’t need client-side hydration.

A full hydration could be provided as well, but should be separated and thereby optional. For example:

// hydration-plugin.js
import %7B ssrRef %7D from '@nuxtjs/composition-api'

export const withHydration = createWithHydration(%7B ref: ssrRef %7D)

// useCounter.js
function useCounterBase() %7B
  /* implementation */
%7D

export const useCounter = withHydration(useCounterBase)

Devtools

What I usually want to know is where, when and how state was changed. The Vuex devtools show which mutations have been commited to which store, at what time that happened and what the payload was. You can analyse the state before and after each mutation and you can also use the time travel feature to reset the store to a previous state.

Let’s look at the full example once more:

export function useCounter() %7B
  const state = reactive(%7B
     count: 0,
   %7D)

  function increment() %7B
     state.count++
   %7D

  return %7B
     state,
     increment
   %7D
%7D

To gather the information we need, we could provide a wrapper for composition functions like useCounter. They would watch the state and wrap all exported methods to track incoming calls. The Vue.js Devtools would then display all this information in the component explorer and in the new timeline.

function useCounterBase() %7B
  /* implementation */
%7D

export const useCounter = withDevtools('Counter', useCounterBase)

However, this approach alone would be very limited in handling the flexibility of composition functions. I believe that we need tools like babel to extend both flexibility and insights during development.

Imagine a more complex composition function:

export function useCounter() %7B
  const state = reactive(%7B
     count: 0,
  %7D)

  const %7B isAuthenticated %7D = useAuthentication()

  const autoIncrementInterval = ref(null)

  function increment() %7B
     state.count++
  %7D

  function startAutoInrement() %7B
    if(!isAuthenticated.value) %7B
      throw new Error('Cannot start auto increment unless authenticated')
    %7D
    autoIncrementInterval = setInterval(increment, 100)
  %7D

  return %7B
    state,
    increment,
    startAutoInrement,
  %7D
%7D

In this example — using the approach described above — we could be aware of the private state generated with ref, but we could not provide any insight apart from its current value. It would be impossible to track internal calls to increment. And unless useAuthentication is wrapped in withDevtools as well, it would be impossible to keep apart which composition function made calls to reactive, ref etc.

A babel plugin however would probably allow us to detect the variable names state and autoIncrementInterval as labels to be display in the Vue.js Devtools. We could track internal calls that would otherwise stay unnoticed. And we would also be able to isolate everything that happens inside useCounter from whats implemented outside.

export const useCounter = withDevtools('Counter', () => %7B
  // code inside `withDevtools` could be transformed with babel
  // to reduce limitations while gathering insights
%7D)

Restrictions

The flexibility of composition functions poses a huge challenge to gather and display the required information. For it to be meaningful and reliable the flexibility could be restricted as necessary where withDevtools is applied.

Possible restrictions are:

  • Functions must be synchronous
    Calls to ref and reactive could then be tracked more easily and reliable.
  • Functions must call either ref or reactive exactly once
    If a composition function creates multiple reactive objects the state displayed in the Vue.js devtools probably becomes harder to understand.
  • Return values must split state and methods
    If a function returned a structure like %7B state, ...methods %7D, where the location of each is well known, tracking method calls and changes to the exposed state would become easier and more reliable.

Closing

With the release of Vue 3, movement has come into the entire ecosystem.
The topic of state management is not exempt from this.

With this blog post I hope to have not only raised questions, but also provided new approaches.
And for some it might just be an overview of the new options and current developments on the topic 😀.

Now my question is: What do you think about all this? Does this approach make sense and would you want to work with it? Is all this realistic - or not?

❤ In any case, I'm happy to receive feedback and questions in the accompanying blogpost on Medium. ❤