This article is part of series on adding React to an existing server side rendered application.

In the last article I covered all the nitty gritty details on how individual, reusable components are built and made available to server-side rendered pages. That all applies to how these mini single page applications are built, as well, so I won’t rehash that here.

What Do You Mean by Mini “SPA”?

Booked is not and never will be a single page application. That said, I have been making sections of the application work as small single page applications (or very large components, I guess). Let’s take a look at an example.

Everything on this page other than the main header and the little footer is a “single page” React application accessible from the /admin/resources/ URL. Add Resource and all the tools in the sub-menu function like a standard React SPA, meaning the URL is updated but there is no trip back to the server to pull down a new “page”.

Here’s the complete Smarty template that renders this SPA.

{include file='globalheader.tpl'}

<div id="page-manage-resources" class="admin-page">
	<div id="react-root"></div>
</div>

{include file="bundle-admin.tpl"}

<script>
	const props = {
          // ... all component props defined here ...
        };

	const root = createRoot(document.querySelector('#react-root'));
	root.render(React.createElement(ReactComponents.ManageResourcesAppComponent, props));
</script>

{include file='globalfooter.tpl'}

Page Structure

When I load /admin/resources/ in my browser, it ends up running /admin/resources/index.php, which renders the ManageResourcesAppComponent, which acts as a Front Controller for this SPA. Let’s take a quick look at that component.

// ... all imports ... //

export function ManageResourcesApp(): ReactElement {
  const { hash, changeHash } = useHashChange();
  const appState = useContext(AppSettingsContext);

  if (appState.isLoading) {
    return <Spinner />;
  }

  if (hash === "") {
    changeHash(NavigationPaths.Home);
  }

  switch (hash) {
    case NavigationPaths.Types: {
      return <ResourceTypesComponent />;
    }
    case NavigationPaths.Add: {
      return <AddResourceComponent />;
    }
    case NavigationPaths.Groups: {
      return <ResourceGroupsComponent />;
    }
    case NavigationPaths.Statuses: {
      return <ResourceStatusesComponent />;
    }
    default: {
      return <ManageResourcesComponent />;
    }
  }
}

export function ManageResourcesAppComponent(props: AppComponentProps): ReactElement {
  initi18({ lang: props.lang, basePath: props.path, version: props.version });
  const api = new ManageResourcesApi(props.path, props.apiEndpoint!, props.csrf);

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <AppSettingsProvider {...props}>
        <ManageResourcesProvider api={api}>
          <ManageResourcesHeader />
          <ManageResourcesApp />
        </ManageResourcesProvider>
      </AppSettingsProvider>
    </Suspense>
  );
}

Look ma! No React Router!

Because my single page applications are so basic, I didn’t see the need to pull in an entire library just for handling what amounts to hash changes. So my “routing” is just including the target component as the hash value. For example, navigating to Add Resource simply changes the URL to /admin/resources/#/add

The App component listens for hash changes and renders the correct component. It’s very simple and works for very simple use cases. If I ever have the need for anything more complex like sub-routes, I’ll reexamine the need for a routing library. Because all the routing logic is contained in a single place, swapping it out should be easy.

The useHashChange hook is pretty simple. I use this hook in any component that needs navigation capabilities.

export function useHashChange(): HashChangeHooks {
  const [hash, setHash] = useState(window.location.hash.split("?")[0]);

  const handleHashChange = (): void => {
    let newHash = window.location.hash;
    if (newHash.includes("?")) {
      newHash = newHash.split("?")[0];
    }
    setHash(newHash);
  };

  const changeHash = (newHash: string): void => {
    window.location.hash = newHash;
    window.scrollTo(0, 0);
  };

  useEffect(() => {
    window.addEventListener("hashchange", handleHashChange, false);

    return () => window.removeEventListener("hashchange", handleHashChange, false);
  }, []);

  return {
    hash,
    changeHash,
    isRootHash: hash === "" || hash === NavigationPaths.Home,
  };
}

SPA Islands

I have a handful of SPA islands in a sea of this large PHP application. They all follow this same pattern and are mostly independent from each other. There are components shared between them, but most of them don’t know any others exist. This keeps the boundaries clean and makes maintenance and troubleshooting easy.

Overall, I think the approach is pretty simple. And I love that React can be used this way, from full SPAs, to SPA islands, to small independent React components.