August 11, 2020Handling promises in Sagas
August 11, 2020Modern Frontend Architecture Part 1: API
May 5, 2020Frontend Pattern: Route Object
May 1, 2020Frontend skills for the Web Designer
April 10, 2020Learning Frontend Path
January 26, 2020Podcasts I listen to (Jan 2020)
January 23, 2020Are you too old to start programming
January 22, 2020Use tabs not spaces
October 18, 2019Refactoring Overgrown React Components slides
October 2, 2019Book Review: Factfulness
October 1, 2019Yes, it's a good time to add TypeScript to your project
September 24, 2019Code Review – Best Practices, Guidelines & Process Insights
June 10, 2019Why I read on the iPad
March 18, 2019Software & Hardware I use
March 16, 2019Why I dont use React Router Link component
November 15, 2018Why public money software is not open source?
July 5, 2018How learning Angular made me better React developer
May 23, 2018My 12 tips how to increase your frontend coding productivity

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.

I assume you make your app based on domain modules, like "Settings", "Products", "Account" etc, not by technical segregation

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.

Because of domain specific behaviour it’s hard to implement common interface for this pattern

Code example

This is highly over-engineered example of this pattern. It has some common examples of possibly useful methods.

See example in Gist

/**
 * 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