Django and Vue3

In the previous articles of this series, I’ve demonstrated a method of integrating Django templates and Vue in such a way that preserves the strengths of both frontend frameworks.

Since those articles were published, Vue3 has been released, bringing a variety of improvements and a list of breaking changes. With these numerous changes, the code examples from the prior articles in this series no longer work directly with Vue3. But happily, with only a few isolated changes, the same general approach works well with Vue3 and, moreover, the code becomes, in my opinion, simpler. In fact, we need not alter our components, our Vuex stores, or even our vue.config.js to work with Vue3. Rather, the required changes are limited to the app initialization logic.

Instead of rehashing the entire series of articles, which explain the ideas and approach in detail, this article will instead enumerate the changes needed to adapt the Vue2 solution to Vue3. At the same time, I’ll introduce a couple of additional changes that, while not strictly necessary for Vue3, improve the overall quality of the Vue + Django integration.

If you’re just starting, I suggest reading from the start of these articles to learn more about the general approach utilized in this article. Or, to get started straight away with Vue3 + Django, check out the sample application source code.

Continued from Part 3

Overview of changes

In Vue2, app initialization was generally done with new Vue() constructor. However, this approach was eliminated in Vue3, so instead we will adapt the code to use the new createApp() method. Similarly, we no longer can instantiate the Vuex store with new Vuex.store() but will instead use createStore().

Both these changes are straightforward adaptations based on the Vue migration guide.

However, our usage with Django necessitates a couple additional changes. First, we must provide for passing of properties from Django template, as our previous approach, which relied on new Vue(), no longer works. Second, as we may potentially be adding several Vue apps to a single page, it behooves us to extract our app/store creation logic into a callable function.

String Property Passing

The new app initialization mechanism renders our old strategy of property passing obsolete. Luckily, Vue3 allows us to pass root properties as the second argument of the createApp method. Thus, we simply need to some way to extract them from our rendered Django template HTML. A simple and intuitive way is to use our root element’s dataset (i.e. its data- attributes) as properties for our app, an approach I first saw described by Michal Levý. An example:

const selector = "#my_app_element";
const mountEl = document.querySelector(selector);
const app = createApp(AppComponent, {...mountEl.dataset});

With this approach, we can adjust our Django templates to pass properties as data attributes.

<div id="hello_world_b" data-msg="">
    <hello-world></hello-world>
</div>

Unfortunately, with this method all properties are passed as strings, as there is no typing data associated with data attributes. This means if a property is intended to represent a different data type, e.g. a number, then the Vue application must explicitly handle this conversion. This is not ideal, as it means we must make special allowances in our Vue application for the limitations of our Django templates.

As the theme of this series of articles is using Django and Vue without sacrificing the strengths of either, let’s eliminate this shortcoming so that we may use our Vue SFCs without this compensation.

Property Passing with Datatypes

We can again utilize the root element’s dataset, this time to pass along the intended property datatype. For example, data-counter-datatype="Number" attribute and value might be associated with a data-counter attribute. We can then update our initialization code above to create a conversion function that checks for the existence these associated datatype attributes and performs custom data conversion as needed.

const datasetDatatypePostfix = "Datatype";

const convertDatasetToTyped = (dataset) => {
   const keys = Object.keys(dataset);
   keys.forEach(function(key){
      let datatypeKey = key + datasetDatatypePostfix;
      if (datatypeKey in dataset) {
          let datatype = dataset[datatypeKey];
          switch (datatype) {
              case "String": //already string, do nothing
                  break;
              case "Number":
                  dataset[key] = Number(dataset[key])
                  break;
              case "Boolean":
                  dataset[key] = dataset[key] === 'true'
                  break;
              // TODO: Add additional datatype conversions
              default: //do nothing
          }
          delete dataset[datatypeKey];

      }
   });
   return dataset;
}

The function accepts an Object representing our root element dataset, and checks for each key if an associated key with Datatype appended exists. If so, the function performs the specified data conversion in situ and removes that datatype description entry, finally returning the resulting dataset. Note I’ve included logic for converting Number and Boolean types only; if your app requires other datatypes, you’ll need to supply your custom conversion logic.

We update our initialization code above then to use this function:

const app = createApp(options, convertDatasetToTyped({...mountEl.dataset}));

Now, we may pass typing information in our Django templates such that our Vue components receive properly typed properties.

Factory Initialization

To avoid repeating the same initialization code throughout our app for each App instance we need to instantiate, we can follow the Vue3 recommendation by creating a utility factory function that will init our apps. Below is a simple implementation that allows us to pass a Vuex store and HTML selector, and will take care of the creating the app, using the store, and mounting to the element described by the selector.

export const createAppInEl = (options, store, selector) => {
    const mountEl = document.querySelector(selector);
    const app = createApp(options, {...mountEl.dataset});
    app.use(store);
    // additional configurations here
    app.mount(selector);
    return app;
}

Any custom configuration, e.g. directives or components, you might have shared among all your apps may be included here.

Store Initialization

Our factory initialization function allows us to pass a Vuex store for use with an application. We’ll create that store using another factory initializer createSharedStore which replaces our old new Vuex.store() code with the new Vue3 createStore() initialization.

Remember in our previous article we leveraged the “vuex-persistedstate” plugin to preserve our state between page loads. We can again use this plugin with Vue3 in an identical manner. However, to prevent this section from being too short, I will take this opportunity to remedy a shortcoming with my prior code.

In our previous implementation, the persisted properties were specified by the initialization code. However, this requires that the initialization code be aware of module and path names. It would cleaner if instead a store could specify if its own persisted state paths¹.

Ideally, we’d like to specify in our Vuex module itself which paths should be persisted, e.g.

export default {
    state: {count: 0},
    mutations: {
        increment: state => state.count++,
        decrement: state => state.count--
    },
    persistentPaths: ["count", ]
}

We can indeed use just this setup with the following update to our persistedstate plugin initialization

createPersistedState({
    paths: Object.entries(modules).map(
        ([mName , m]) => 'persistentPaths' in m 
            ? m.persistentPaths.map(path => mName + "." + path) 
            : []
    ).flat(),
})

where modules is a list of zero or more Vuex modules as defined above. This code will check for the existence of the persistentPaths list property in each module, and if it exists it will prepend the module name to each specified property and flatten into a single combined list of fully qualified paths, which will then be persisted in our application.

Putting all this together to create a reusable store initialization factory function:

export const createSharedStore = (modules) => {
    return new createStore({
        plugins: [
            createPersistedState({
                paths: Object.entries(modules).map(
                    ([mName , m]) => 'persistentPaths' in m 
                        ? m.persistentPaths.map(path => mName + "." + path) 
                        : []
                ).flat(),
            })
        ],
        modules: modules,
        strict: process.env.NODE_ENV !== "production",
    });
}

¹: The argument can also be made that the initializing code should be precisely where the specification of persistent paths should be, in that a module itself should not concern itself with whether its paths are persisted or not. Conceptually, I agree, but in this case I find that the convenience of placing the entire description of a module (including its persisted condition) in a single tidy file outweighs the self-satisfaction of a inviolable separation of concern.

Conclusion

That’s the extent of the changes to needed to put your Django and Vue3 frontends in beautiful harmony. The primary changes necessitated by Vue3 are a result of its new app initialization mechanism, but in the spirit of Vue3, we’ve taken the opportunity to improve and streamline our setup.

Source

View the full example Django/Vue app or compare the changes from Vue2 to Vue3.