DEV Community

Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on

A Symfony - React SPA application. The Reload problem

Introduction

In this article, I would like to share with you a problem I encountered while developing a SPA in React within a Symfony project and how I resolved it.

The Context

I'm working on a Symfony based application which uses Symfony UX to integrate a React-SPA frontend within the Symfony application. The React frontend is loaded using a symfony controller which renders a twig file which renders the main react component:

#[Route('/app', name: 'get_app', methods: ['GET'])]
public function getApp(): Response 
{
    return $this->render('App.html.twig');
}
Enter fullscreen mode Exit fullscreen mode

The twig file simply renders the main react component which loads the react SPA.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
        <meta name="description" content="" />
        <meta name="author" content="" />
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}
            {{ encore_entry_link_tags('app') }}
        {% endblock %}
        {% block javascripts %}
            {{ encore_entry_script_tags('app') }}
        {% endblock %}
    </head>
    <body>
        <div {{ react_component('App') }} ></div>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The react main AppComponent looks like this:

export default function App() {

    const wallet: Wallet = useStellarWallet(WalletNetwork.TESTNET);

    return (
      <Fragment>
        <CssBaseline />
        <WalletContext.Provider value={wallet}>
          <Router>
            <Routes>
              <Route path="/login" element={<SignIn />} />
              <Route path="/app" element={<ProtectedRoute children={<Layout /> } /> } >
                  <Route element={<Home />} />
                  <Route path="home-investor" element={<HomeInvestor />} />
                  <Route path="blogs" element={<Blogs />} />
                  <Route path="create-project" element={<CreateInvestmentProject />} />
                  <Route path="project/:id/start" element={<StartInvestmentProject />} />
                  <Route path="project/:id/invest" element={<SendInvestmentDeposit />} />
              </Route>
            </Routes>
          </Router>
        </WalletContext.Provider>
    </Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

As you can see, it uses the React Router component to define the routes tree.

The Problem

I use the react-router to manage the react SPA navigation within the useNavigate hook. This is an efficient way to navigate between routes and it works like a charm.
The problem is that, as I'm integrating react within a Symfony application, if I reload the application (on the browser), I get a Symfony error which tells me that such route does not exist. This is normal since the routes are defined in react, not in Symfony so the Symfony router cannot find them.

A partial solution

The first thing to do I thought about was to allow an slug on the "get_app" controller so that it would look like this:

#[Route('/app', name: 'get_app', methods: ['GET'])]
#[Route('/app/{slug}', name: 'get_app_with_slug', methods: ['GET'])]
public function getApp(): Response 
{
    return $this->render('App.html.twig');
}
Enter fullscreen mode Exit fullscreen mode

This works when the slug value is a simply string like this:

https://127.0.0.1:8000/app/home-investor
Enter fullscreen mode Exit fullscreen mode

But the symfony router will throw an error if the slug part contains a route with extra segments:

https://127.0.0.1:8000/app/project/2/invest
Enter fullscreen mode Exit fullscreen mode

This is because the route "/app/{slug}" only captures a single segment after "/app/". This means that any URL with more than one segment after "/app/" will not match this route. For example, in the URL "https://127.0.0.1:8000/app/project/2/invest", there are three segments after "/app/" (project, 2, invest), which causes it not to match the route definition.

The Solution

The solution I finally chose involved a series of changes both in the backend part (Symfony) and in the frontend part (react). Let's start with the Symfony part.

Changes on the Symfony part

The first change of the symfony part was to create a Kernel Subscriber to catch the incoming request using the Symfony
KernelEvents::REQUEST event. Let's see the code:

public static function getSubscribedEvents(): array
{
    return [
        KernelEvents::REQUEST   => 'onRequest'
    ];
}

public function onRequest(RequestEvent $event): void
{
   $request = $event->getRequest();
   if (preg_match('#^\/app\/#', $request->getRequestUri())) {
       $pathSlug  = preg_replace('#^\/app\/#', '', $request->getRequestUri());
       $url = $this->router->generate('get_app', ['qslug' => $pathSlug]);
       $event->setResponse(new RedirectResponse($url));
   }
}
Enter fullscreen mode Exit fullscreen mode

The onRequest function gets the Symfony Request object and extracts the "react route path" from the request uri. Then, it generates the "get_app" route passing it a parameter named "qslug" with the react route path value.
Finally, it sets the new response to the event as a Symfony RedirectResponse.

The second change involves modifying the "get_app" controller so that it passes the react route path to the App.html.twig file.

#[Route('/app', name: 'get_app', methods: ['GET'])]
public function getApp(#[MapQueryParameter] ?string $qslug): Response 
{
    return $this->render('App.html.twig', ['pathSlug' => $qSlug]);
}
Enter fullscreen mode Exit fullscreen mode

Finally, The twig file also has to pass the slug to the react app component.

<!DOCTYPE html>
<html>
    <!-- Head -->
    <body>
        <div {{ react_component('App', {pathSlug: pathSlug}) }} ></div>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Changes on the React part

Now in the react part, I needed to check whether this path had a value and, if so, navigate to it.

First of all, I created a hook named "useReloadedRoute":

export const useReloadedRoute = (path?: string) => {
    if(path){
        localStorage.setItem('route_to_redirect', path);
    }

    const getRouteToNavigate = () => {
        return localStorage.getItem('route_to_redirect');
    }

    const removeRouteToNavigate = () => {
        localStorage.removeItem('route_to_redirect');
    }

    return {
        getRouteToNavigate,
        removeRouteToNavigate
    };
}
Enter fullscreen mode Exit fullscreen mode

The hook saves the path in the localStorage and provides a function to get the path to redirect and another one to remove it.

Then, I created a react context within the AppComponent to provide the reloaded route.

export const ReloadRouteContext = createContext<ReloadedRoute>(null);

interface AppProps {
  pathSlug?: string
}

export default function App(props: AppProps) {

    const wallet: Wallet = useStellarWallet(WalletNetwork.TESTNET);
    const reloadedRoute: ReloadedRoute = useReloadedRoute(props?.pathSlug);

    return (
      <Fragment>
        <CssBaseline />
          <ReloadRouteContext.Provider value={reloadedRoute} >
          <WalletContext.Provider value={wallet}>
            <Router>
              <Routes>
                <Route path="/login" element={<SignIn />} />
                <Route path="/app" element={<ProtectedRoute children={<Layout /> } /> } >
                    <Route element={<Home />} />
                    <Route path="home-investor" element={<HomeInvestor />} />
                    <Route path="blogs" element={<Blogs />} />
                    <Route path="create-project" element={<CreateInvestmentProject />} />
                    <Route path="project/:id/start" element={<StartInvestmentProject />} />
                    <Route path="project/:id/invest" element={<SendInvestmentDeposit />} />
                </Route>
              </Routes>
            </Router>
          </WalletContext.Provider>
          </ReloadRouteContext.Provider>
      </Fragment>
    );
}
Enter fullscreen mode Exit fullscreen mode

Thanks to the ReloadRouteContext, I was ready to check for the path to redirect in the LayoutComponent.

const reloadedRoute: ReloadedRoute = useContext(ReloadRouteContext);

useEffect(() => {
   const rlRoute = reloadedRoute.getRouteToNavigate();
   if (rlRoute) {
       const path = '/app/' +rlRoute;
       reloadedRoute.removeRouteToNavigate();
       navigate(path);
   }
}, []);
Enter fullscreen mode Exit fullscreen mode

Now, if there is a route to redirect stored, the LayoutComponent will remove it from the storage and navigate to it.

Conclusion

This is the way I resolved this issue. I hope that if you've experienced the same problem this solution can help you. Of course, if you know a better way to achieve that, please let me know in the comments so I can test it :).

If you like my content and it provides value to you, consider reading my book: Building an Operation Oriented Api using PHP and The Symfony Framework

Top comments (5)

Collapse
 
xwero profile image
david duymelinck

By adding a default value to the parameter the fragments are optional.

#[Route('/app/{first}/{second}/{third}', name: 'get_app', methods: ['GET'])]
public function getApp(mixed $first = null, mixed $second = null, mixed $third = null): Response 
{
   return $this->render('App.html.twig');
}
Enter fullscreen mode Exit fullscreen mode

That should fix your problem.

Collapse
 
icolomina profile image
Nacho Colomina Torregrosa

Hey david. Thank you!
Yes, it could work but there could be front paths with more than 3 segments

Collapse
 
xwero profile image
david duymelinck

The code is not perfect, but I rather have one less subscriber and no ReloadRouteContext.
I'm not very good at React so I asked chatGTP. And the navigate method could cause a repaint. And as far as I know that is not good.

It is a pure frontend route, so I would have this in a controller with all the other React routes. And add a comment in the React code warning that if there are new segments added to an url, there should be a change to the segments for that route in the controller.
That way frontend and backend people can still work independent from each other.

Collapse
 
labfordev profile image
labfordev

Otherwise, you can just do this, right?

#[Route('/app/{slug}', name: 'get_app_with_slug', methods: ['GET'], requirements: ['slug' => '.+'])]

Collapse
 
icolomina profile image
Nacho Colomina Torregrosa

Hey, thanks for commenting!
I've just checked it but it's not worked :(