Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Async/Dynamic import()

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

One of the bigger items in the analyzer - though not the biggest, we'll get to those later - is react-dom. It's... kind of unfortunate that the user needs to download this immediately on every page... only to render a pretty unimportant spot on the footer... or a sidebar that exists on just one page. Can we... improve that situation somehow?

We can! And we have two ways!

The first is called an async or dynamic import and this strategy can be used inside any JavaScript file: it has nothing to do with Stimulus. I'll show that one first. But in the next video, we're going to learn about a trick that's special to Stimulus and Symfony... a trick that I'm super excited to show you.

Using import() As a Function

Over in the project - let me close a few files - head up to assets/controllers/, open featured-product-react_controller.js and also made-with-love_controller.js. These are the two files that use react-dom.

Remove the import on top for react-dom. Now, inside of connect(), re-add the import, but use it like a function: import('react-dom'). Add a .then() to the end of this with an arrow function that receives the ReactDOM module as an argument. Move the ReactDOM.render() stuff into this function... and the last thing we need to do is add .default.


In React 18, the code to render a component is slightly different:

import('react-dom/client').then((ReactDOM) => {

... lines 1 - 4
export default class extends Controller {
... lines 6 - 9
connect() {
import('react-dom').then((ReactDOM) => {
<FeaturedProduct product={this.productValue} />,

That's it! This is called an async or dynamic import and we've talked about this a few times before on Symfonycasts. This allows Webpack to isolate the react-dom code into its own file. Then, that code won't be downloaded until this import line is executed. Yup, it'll basically be downloaded as an Ajax call... which is why we have the .then(): the import()function returns a Promise. We could have also used await. Oh, and when you use a dynamic import, if you're importing the "default" module from a file... you need to add the .default. It's kind of weird, but simple enough.

Anyways, here's the big point! Thanks to this, the react-dom JavaScript code won't be downloaded on initial page load. But the moment that a data-controller element appears on the page for this controller, the connect() will be called, react-dom will be downloaded and the component will render. That's amazing.

The only downside is that there may be a slight delay before this component renders. If that's a problem, you can add a loading animation in your controller element's HTML... which will get replaced as soon as the component renders.

Let's repeat this in the other controller so that react-dom isn't imported by any modules on page load. Copy this entire block, go to the other file, remove the import on top, paste the new code below... then bring up our original ReactDOM.render() code. Don't forget to add the .default and... we don't need this import: PhpStorm added that when I pasted the code.

... lines 1 - 4
export default class extends Controller {
connect() {
import('react-dom').then((ReactDOM) => {
<MadeWithLove />,

Beautiful! First, let's make sure we didn't break anything. Head back to your terminal. You can see that the webpack-bundle-analyzer server is still running. Stop that with Ctrl+C and then run our usual:

yarn watch

Over at the browser - I'll wait for that to finish... done! Refresh the page and... yes! I saw a slight delay, but it works!

Seeing Async Imports in your Network Tools

Go check out your browser's network tools - filter this to only show JavaScript files. Let's see: the page downloaded runtime.js, a long vendors filename and app.js. Those are the only three JavaScript files that are being initially loaded on the page. I know that because of the "Initiator" column. It says "register" - that's the URL for this page. That tells me that these are actual script tags in my HTML.

But for the last one - this vendors-node_modules_react-dom.js file - its initiator is something called "load script". That's a deep function from inside Webpack itself! This tells me that Webpack downloaded this script file asynchronously after the page loaded!

We can see this on the Waterfall timeline on the right: this file started downloading way after the other ones. It didn't start downloading until the connect() method on our controller was called.

Analyzing our Async Import Improvements

This is awesome! But to prove that we've made good improvements, let's rerun our analyze commands. Back at the terminal, hit Ctrl+C to stop Encore. Then run the yarn build command from earlier to get a fresh stats.json file:

yarn run --silent build --json > stats.json

Since we're doing a production build... this will take a lot more time than a normal build. Once it finishes, start the analyzer:

yarn webpack-bundle-analyzer stats.json public/build

And... ooh! Let me close the sidebar. Ah, this is nice! Notice that react-dom is now isolated into its own file. Close a few tabs... then view the source of the page. Actually let me refresh first... then view source.

Once again, the page has 3 script tags. This tells us that runtime.js, 643.js and app.js are the only files that are being downloaded on page load.

That proves that the react-dom file is not being immediately downloaded! The amount of code that the user needs to download before the page starts working just got a bit smaller... which makes our site load faster.

So... async imports are super fun. They're a native Webpack feature that you can use in any file, even outside of a Stimulus controller.

But I want to go further. Could we make an entire controller lazy? What I mean is: could we avoid downloading all of the code from a controller and the code from all of its dependencies... until an element for that controller appears on the page?

And what about the controllers from Symfony UX? Like the one for chartjs? That controller imports the chart.js library... which is huge and only used on the admin page. Could we make that entire controller load lazily?

You can probably guess that the answer is... yes! A huge yes! Let's learn how next.

Leave a comment!

Login or Register to join the conversation
Waldifubu Avatar
Waldifubu Avatar Waldifubu | posted 7 months ago | edited

Maybe somebody needs an updated example,
here you are:

    connect() {
        import('react-dom/client').then((ReactDOM) => {
1 Reply

Hey Waldifubu,

Thank you for sharing this tip! Could you share a bit more information about it? Are you on a newer version of React? Which one exactly? It would help others better I think, I see we're on v17.0.1 in this course.



I actually should have caught this! Thank you @Mepcuk! I help maintain the ux-react package - and you can see the style of code THERE that is suggested here: https://github.com/symfony/ux/blob/2.x/src/React/assets/src/render_controller.ts

I'll add a note - the change was in React 18.

1 Reply

Issue - uploaded video filename is incorrect - 20-async-imports.mp4
P.s. should be 29-async-imports


Thanks for the reporting, we take care of that :)



What the difference between ReactDOM.defaul.render and ReactDOM.render ? Why it default needed? Please expain.

Thanks in advance


Hey Mepcuk

That's a good question. To be honest, I don't really know how it works internally but what's happening here is that Webpack returns a different object when you do an "async import" instead of a "normal import". So, in short, doing this

import ReactDom from 'react-dom';

will set the same object in the ReactDom variable as if you do this:

import('react-dom').then((ReactDOM) => {
     const RD = ReactDom.default;

I hope it makes sense to you. Cheers!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works perfectly with Stimulus 3!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "", //
        "doctrine/annotations": "^1.0", // 1.11.1
        "doctrine/doctrine-bundle": "^2.2", // 2.2.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.8", // 2.8.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^5.6", // v5.6.1
        "symfony/asset": "5.2.*", // v5.2.3
        "symfony/console": "5.2.*", // v5.2.3
        "symfony/dotenv": "5.2.*", // v5.2.3
        "symfony/flex": "^1.3.1", // v1.18.5
        "symfony/form": "5.2.*", // v5.2.3
        "symfony/framework-bundle": "5.2.*", // v5.2.3
        "symfony/property-access": "5.2.*", // v5.2.3
        "symfony/property-info": "5.2.*", // v5.2.3
        "symfony/proxy-manager-bridge": "5.2.*", // v5.2.3
        "symfony/security-bundle": "5.2.*", // v5.2.3
        "symfony/serializer": "5.2.*", // v5.2.3
        "symfony/twig-bundle": "5.2.*", // v5.2.3
        "symfony/ux-chartjs": "^1.1", // v1.2.0
        "symfony/validator": "5.2.*", // v5.2.3
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.1
        "symfony/yaml": "5.2.*", // v5.2.3
        "twig/extra-bundle": "^2.12|^3.0", // v3.2.1
        "twig/intl-extra": "^3.2", // v3.2.1
        "twig/twig": "^2.12|^3.0" // v3.2.1
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.2.3
        "symfony/maker-bundle": "^1.27", // v1.30.0
        "symfony/monolog-bundle": "^3.0", // v3.6.0
        "symfony/stopwatch": "^5.2", // v5.2.3
        "symfony/var-dumper": "^5.2", // v5.2.3
        "symfony/web-profiler-bundle": "^5.2" // v5.2.3

What JavaScript libraries does this tutorial use?

// package.json
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.12.13
        "@popperjs/core": "^2.9.1", // 2.9.1
        "@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
        "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
        "@symfony/webpack-encore": "^1.0.0", // 1.0.4
        "bootstrap": "^5.0.0-beta2", // 5.0.0-beta2
        "core-js": "^3.0.0", // 3.8.3
        "jquery": "^3.6.0", // 3.6.0
        "react": "^17.0.1", // 17.0.1
        "react-dom": "^17.0.1", // 17.0.1
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "stimulus": "^2.0.0", // 2.0.0
        "stimulus-autocomplete": "^2.0.1-phylor-6095f2a9", // 2.0.1-phylor-6095f2a9
        "stimulus-use": "^0.24.0-1", // 0.24.0-1
        "sweetalert2": "^10.13.0", // 10.14.0
        "webpack-bundle-analyzer": "^4.4.0", // 4.4.0
        "webpack-notifier": "^1.6.0" // 1.13.0