How to Implement JavaScript Code Splitting in Phoenix 1.7: A Step-by-step Guide

Today, I faced a challenge in my Phoenix project. I'm using a JavaScript markdown editor with a hook on one of the forms. This ended up significantly increasing the size of my app.js file.

Luckily, esbuild offers a feature called code splitting, which came to my rescue.

Here's the recipe for how to set up JS code splitting in Phoenix 1.7.

Firstly, modify the config/config.exs as follows:

config :esbuild, version: "0.14.41", default: [ args: ~w(js/app.js --chunk-names=[name]-[hash] --bundle --target=es2022 --main-fields=module,main,exports --outdir=../priv/static/assets --splitting --format=esm --external:/fonts/* --external:/images/* ), cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ]

Notes on options:

  • --target=es2022: I'm using a very modern target, es2022. For my use case, I don't need to support old browsers.
  • --main-fields=module,main,exports: This is extremely helpful if you want to load a specific file from a JS library. Refer to main-fields for more details.
  • --format=esm: Code splitting is still a work in progress (WIP) and only functions with the esm format.

Next, edit the root.html.heex template where the JavaScript is loaded. Remember to add the type="module" attribute:

<script type="module" defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> </script>

Then, update your hook to dynamically load the library in the mounted section:

const ByteMd = { mounted() { import('bytemd').then(({ Editor }) => { const targetId = this.el.dataset.targetId this.$target = document.getElementById(targetId) this.editor = new Editor({ target: this.el, props: { value: this.$target.value }, }) this.editor.$on('change', this.onChange.bind(this)) }) }, onChange(event) { this.editor.$set({ value: event.detail.value }) this.$target.value = event.detail.value }, } export default ByteMd

You can check out this short demo video to see it in action:

When the hook is mounted for the first time, the JavaScript chunk that contains the 'bytemd' library is fetched via an AJAX request. Any subsequent mounts won't request the library again.

Before concluding, there's an important consideration when dealing with code splitting and module loading. In situations where the module is not immediately available, you might want to hide the relevant <div> or display a loading spinner in your template. This could help enhance the user experience by signaling that the application is processing something in the background.

Once the module import is complete, you can remove the spinner or the hidden class. Here's an example of how you can accomplish this:

attr :id, :string, required: true attr :target_id, :string, required: true def byte_md(assigns) do ~H""" <div id={"container-#{@id}"} phx-update="ignore"> <div id={@id} phx-hook="ByteMd" class="hidden" data-target-id={@target_id} /> </div> """ end
const ByteMd = { mounted () { import('bytemd').then(({ Editor }) => { ... // Remove the 'hidden' class after the module is loaded this.el.classList.remove('hidden'); }) } ... }

With this approach, your application handles module loading in a more graceful and user-friendly way.

And now, we're genuinely done! You have successfully set up JS code splitting in your Phoenix 1.7 project. Happy coding!

Stay up to date

Sign up for the mailing list and get notified via email when new blog posts come out.