DEV Community

Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on

1

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

Postmark Image

Speedy emails, satisfied customers

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up

Top comments (9)

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.

Thread Thread
 
icolomina profile image
Nacho Colomina Torregrosa

Hey, I've finally chosen your way ad it is better because you do not have to change anything on the react part so there is no need to repaint after navigate to the path slug. I've finally created the Symfony route like this:

#[Route('/app', name: 'get_app', methods: ['GET'])]
#[Route('/app/{seg1}', name: 'get_app_seg1', methods: ['GET'])]
#[Route('/app/{seg1}/{seg2}', name: 'get_app_seg2', methods: ['GET'])]
#[Route('/app/{seg1}/{seg2}/{seg3}', name: 'get_app_seg3', methods: ['GET'])]
#[Route('/app/{seg1}/{seg2}/{seg3}/{seg4}', name: 'get_app_seg4', methods: ['GET'])]
public function getApp(?string $seg1, ?string $seg2, ?string $seg3, ?string $seg4): Response 
{
      return $this->render('App.html.twig');
}
Enter fullscreen mode Exit fullscreen mode

I have allowed up to 4 segments (do not think there will be more).
Thanks for you advice!

Thread Thread
 
xwero profile image
david duymelinck

You don't need to create different routes for the different segments. That is why there is a default value for every method argument.

Thread Thread
 
icolomina profile image
Nacho Colomina Torregrosa

Yes but, with a single route this does not work:
"127.0.0.1:8000/app/seg1"
but this does: "127.0.0.1:8000/app/seg1/".
That's because I've had to create different routes.

Thread Thread
 
xwero profile image
david duymelinck

Is there some config that requires the end slash for urls? That is the only reason I can think of that the url without the end slash doesn't work.

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 :(

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs