Frontend Pattern: Route Object
One of the challenges every SPA will have to solve is routing across the pages. Here is the pattern I use in Codibly to keep proper separation of concerns & nice abstraction of resolving routes.
Problem
There are way problems I face:
Routes are used by strings
Often I see routing like <Link to="/account" />
or history.push("/account")
. This is error prone.
Routes are handled at several places
Different routes redirect across each other. Sometimes we need to do some extra operations like settings params or query string.
Eventually we end up with duplicated logic like history.push("/product/:id".replace(':id', someID))
.
If we change the route we have to keep in mind to change it everywhere.
Enums are not enough
You might think patterns like enum ProductRoute { MAIN = '/products', SINGLE = '/products/:id' }
will solve the problem.
However quite soon you will need to replace params in routes or do other url manipulation. Changing URL segments and query params
is common scenario and sooner or later your domain can grow to spaghetti mess (if you don't think about it soon enough).
Solution
Abstraction on resolving routes, URLs etc.
Each domain which provides routing (e.g Settings domain because it handles /settings
path with it’s dependencies)
should have have Route Object. It can be Settings.route.ts
file with named export const SettingsRoute
.
Code example
This is highly over-engineered example of this pattern. It has some common examples of possibly useful methods.
/**
* Create a class/service, possibly don't export it so it's not confused with it's instance
*/
class SettingsRoute {
/**
* Expose public routes
* Can be also as getter functions - possible even better!
*/
public MAIN = `${this.baseUrl}settings`;
public SECURITY = `${this.baseUrl}settings/security`;
public NOTIFICATIONS = `${this.baseUrl}settings/notifications`;
/**
* Store some internal params so rest of app doesn't have to bother
*/
private passwordTokenParam = "token";
/**
* Provide base url prefix, so module can be mounted deeper in URL.
*/
constructor(private baseUrl = "/") {}
/**
* Don't let other parts of app decide where user goes by default.
* This is Settings Domain responsibility.
*/
getDefaultRoute(): string {
return this.NOTIFICATIONS;
}
/**
* Method like this can be used in inner navigation of settings panel etc.
*/
getAllRoutesSections(): string[] {
return [this.SECURITY, this.NOTIFICATIONS];
}
/**
* Add some function resolving some more complex url with params.
* Thanks to this there is only one place that handles this route both ways
*/
getPasswordChangeLinkWithToken(token: string): string {
return `${this.SECURITY}?${this.passwordTokenParam}=${token}`;
}
/**
* Opposite way to resolving, it should expose functions that parse some params
*/
getTokenFromPasswordChangeUrl(url: string): string {
const params = new URLSearchParams(url);
return params.get(this.passwordTokenParam);
}
}
/**
* Expose builder to encapsulate constructing logic
*/
export const SettingsRouteBuilder = {
build(options?: { baseUrl?: string }): SettingsRoute {
return new SettingsRoute(options?.baseUrl);
}
};
/**
* Export created route object instance
*/
export const settingsRoute = new SettingsRoute();
Alternatives:
- If route is simple, you can use simple object instead of class - just keep the interface open for extending.
- You can use getter methods to encapsulate fields, they grant extra decoupling
- You can use more functional approach with functions composition instead of class (however I think class is an elegant solution).
- You can use static class to avoid constructing and still achieve some readability you will not get with object literal.
- You can use namespace with exported functions instead of class
Do
- Encapsulate resolving and parsing urls logic to some abstraction
- Each domain's route operation should be delegated to this object
- Keep one route object per domain
- Use constructor to pass options like route prefix (for nested routes)
Don't
- Don’t use enums, because they can’t have functions and you will most likely need them