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');
}
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>
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>
);
}
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');
}
This works when the slug value is a simply string like this:
https://127.0.0.1:8000/app/home-investor
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
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));
}
}
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]);
}
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>
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
};
}
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>
);
}
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);
}
}, []);
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)
By adding a default value to the parameter the fragments are optional.
That should fix your problem.
Hey david. Thank you!
Yes, it could work but there could be front paths with more than 3 segments
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.
Otherwise, you can just do this, right?
#[Route('/app/{slug}', name: 'get_app_with_slug', methods: ['GET'], requirements: ['slug' => '.+'])]
Hey, thanks for commenting!
I've just checked it but it's not worked :(