/

React 18 inside a Symfony monolith, and the parts that hurt

1,790 words, about 8 min read

The first React component landed in a Twig template on 2 May. The gzipped JavaScript for that page went from 41 kB to 124 kB. I put both numbers in the pull request description because it was the only honest way to describe the trade: a shipment table that used to refresh by reloading the whole page now updates in about 40 ms, and every visitor pays 83 kB for it.

That page lives in a Symfony 6.0 monolith on PHP 8.1. Server-rendered Twig, forms built with the Form component, validation constraints on the entities, Webpack Encore compiling four entries. I have been writing jQuery against templates like this since 2014 and Vue against them since 2019. React 18 came out on 29 March 2022 and we started on 18.1.0, which shipped on 26 April. This is what the first three weeks actually cost.

The reason we picked React was staffing

I want to be straight about this because the technical justifications people write afterwards are usually reverse engineered. Two of us already knew React well. The client’s own developers knew React and nobody there knew Vue. Our Vue code was Vue 2, and a Vue 3 migration would have been its own quarter of work with no new features at the end of it. So the decision was about who can maintain the thing in two years, not about rendering models.

If you are choosing a front end library for a server-rendered PHP app, I think that is the correct way to choose, and I would say so in a code review. The rendering differences between the options barely matter at the scale of one interactive table. The differences in who can pick up the file at 9pm matter a lot.

Mount points, not an application

We did not build a single page application. Symfony still routes, still renders the page, still owns the layout. React owns rectangles inside it. Enabling that in Encore is one line, .enableReactPreset(), after which .js and .jsx files go through babel-preset-react.

Server state gets to the component through data- attributes. The Symfony docs recommend exactly this, including the part people skip: run the JSON through the html_attr escaping strategy, because the default HTML escaping will not save you inside an attribute.

{# templates/shipment/index.html.twig #}
<div class="js-react-mount"
     data-component="ShipmentTable"
     data-endpoint="{{ path('admin_shipment_search') }}"
     data-props="{{ props|json_encode|e('html_attr') }}"
></div>

{{ encore_entry_script_tags('shipments') }}

That held until a props payload hit 8 kB and the rendered HTML became impossible to read in the browser inspector. For those two pages we moved the payload into a <script type="application/json"> block instead, encoded with JSON_HEX_TAG so a closing script tag inside the data cannot break out. Attributes are better for a handful of scalars. A script tag is better for a document.

The mount file is plain JavaScript with no JSX, so it stays readable next to the Twig it serves:

// assets/react/mount.js
import { createRoot } from 'react-dom/client';
import { StrictMode, createElement } from 'react';
import ShipmentTable from './ShipmentTable';

const registry = { ShipmentTable };
const roots = new WeakMap();

export function mountAll(scope = document) {
    scope.querySelectorAll('.js-react-mount').forEach((el) => {
        if (roots.has(el)) {
            return;
        }

        const Component = registry[el.dataset.component];
        if (!Component) {
            throw new Error('Unknown React component: ' + el.dataset.component);
        }

        const root = createRoot(el);
        roots.set(el, root);

        root.render(createElement(StrictMode, null,
            createElement(Component, {
                ...JSON.parse(el.dataset.props || '{}'),
                endpoint: el.dataset.endpoint,
            })
        ));
    });
}

export function unmount(el) {
    const root = roots.get(el);
    if (root) {
        root.unmount();
        roots.delete(el);
    }
}

The WeakMap is not decoration. React 18 replaces ReactDOM.render with createRoot from react-dom/client, and replaces unmountComponentAtNode with root.unmount(), which means you now have to keep the root object around to tear anything down. We have a modal that mounts a component on open and closes on Escape. For two days it created a new root on every open, over the same container, and the old ones stayed alive. Memory went up in a straight line while you clicked. The upgrade guide is blunt that the old API only gets you React 17 behaviour, and that none of the new features work without createRoot, so there was no way to avoid learning this.

StrictMode found two real bugs and cost me an afternoon

In development, React 18’s StrictMode mounts a component, destroys its effects, and then mounts it again with the previous state. The upgrade guide lists the sequence step by step. The point is to prove your effects survive being run twice, because a future React wants to remove and restore parts of the tree while keeping state.

My first reaction was to take StrictMode out. The guide even tells you that you may do that and add it back later. I left it in, and within an hour it had found two bugs that were already in our Vue code and had been there for a year.

The first was a fetch with no cleanup. Two requests went out in development, which is what tipped me off, but the actual defect was that a slow response could resolve after the component had gone and set state into nothing. The fix is the cleanup function React has always offered and I had been too lazy to write:

useEffect(() => {
    const controller = new AbortController();

    fetch(endpoint + '?' + new URLSearchParams(filters), {
        signal: controller.signal,
        headers: { 'X-CSRF-Token': csrfToken },
    })
        .then((response) => response.json())
        .then((data) => setRows(data.rows))
        .catch((error) => {
            if (error.name !== 'AbortError') {
                setError(error.message);
            }
        });

    return () => controller.abort();
}, [endpoint, filters, csrfToken]);

The second was a resize listener added in an effect and never removed. With the double invoke we ended up with two, which made the bug visible in a way that a year of production never had. Neither of these is a React 18 problem. React 18 is just the first thing that pointed at them.

Automatic batching, and the one place we had to opt out

Before 18, React batched state updates inside its own event handlers and nowhere else. Updates inside a promise, a setTimeout or a native listener each caused their own render. With createRoot, everything batches. The upgrade guide calls this a breaking change, and for us it broke exactly one thing.

We had code that set state in a promise handler and then immediately measured an element with getBoundingClientRect to decide whether to scroll a panel into view. Under React 17 the DOM had already been written by the time the next line ran. Under 18 it had not, so the measurement came back as the old layout and the panel scrolled to the wrong place about half the time. flushSync is the documented escape hatch:

import { flushSync } from 'react-dom';

flushSync(() => setExpanded(true));
panelRef.current.scrollIntoView({ block: 'nearest' });

One call site in the whole codebase. I would rather have batching by default and one flushSync than the other way round.

CSRF, and the validation we now write twice

Symfony forms carry a CSRF token and check it for you. The moment you post from fetch, that stops being free. The CSRF documentation covers the manual path: generate with the csrf_token() Twig function, verify with isCsrfTokenValid(). We pass the token in the props payload and send it as a header, and the controller does this before anything else:

if (!$this->isCsrfTokenValid('shipment_search', $request->headers->get('X-CSRF-Token', ''))) {
    return new JsonResponse(['error' => 'invalid_csrf'], 403);
}

The token lives in the session, which the docs point out means a page holding one cannot be fully cached. That was already true of our forms, so it cost us nothing new.

The duplicated validation is the part I have not solved. The reference field is 8 to 20 characters and must match a pattern. That rule exists as a Symfony constraint on the entity, and it now also exists in the React form so the user gets feedback before a round trip. Two files, one rule, no mechanism keeping them honest. We considered serialising the constraints into the props payload and generating the client checks from them. We did not build it, because the three shapes of validation we actually have did not justify a small framework, and a half-built one would be worse than the duplication. So the server stays the authority, the client copy is a convenience, and when they drift the server wins and the user sees a round trip. It is the weakest part of this work.

Where it was the wrong tool

We converted six page regions. Two of them should not have been converted. A settings panel with eleven fields and no interaction beyond submit was a Symfony form that I turned into a React form for consistency, which is not a reason. A read-only report with a date filter needed a form and a page load. Both are back on Twig now, the second one with about thirty lines of plain JavaScript.

The rule we settled on: React earns its place when two or more widgets on the page share state and the interaction has to be faster than a page load. Below that bar, Twig plus a small script is less code, less build, and fewer bytes.

The other thing worth saying is that we use none of React 18’s headline features. Concurrent rendering is opt-in, and it only turns on where you use a concurrent feature. We have no startTransition and no useDeferredValue anywhere, because nothing we render is slow enough to need them. Suspense for data fetching is out too. The release post is explicit that it works today in opinionated frameworks like Relay, Next.js, Hydrogen or Remix, and we are a Symfony application, not one of those, and not going to become one.

So the next item on my list is not concurrency. It is the 83 kB. Splitting the vendor chunk so the three React pages share one copy of react-dom should take most of it back, and if it does not, one of those three pages is going back to Twig.

Sources