Recently, the functional paradigm has become more popular in the frontend community and elsewhere. I think it's wonderful because functions are much easier to write and test, this gives us more maintainable codebase, and we all know that maintenance is a pain. I won't describe the functional paradigm in depth, but I'll use its principles. If you want to read about functional programming then you could read the article, Functional Programming For The Rest of Us and watch the slides, Functional Programming.
In addition to functional paradigm, the frontend community is developing ideas about isomorphic code and isomorphic applications. In short, isomorphic code runs on both sides, client and server. The pros are obvious — you can use the same code on both platforms and not repeat yourself.
To describe the meaning of isomorphic applications I should tell you about the main problem of modern client-side web applications. These days everyone owns some type of mobile device but the cellular network isn't good enough, which makes developers think about performance and interactivity. Loading time can be optimized on classic websites but they are not interactive enough. The opposite is true of web applications, which are much more interactive but have a long initialization process.
These problems are solved in isomorphic applications. Using isomorphic code, the server could initialize an instance of the client application for every request. This means that the server can send a pre-rendered application screen to the client and the client could show it to the user while other resources are still loading. After all resources have been loaded by the client, it could initialize its own instance of the application and replace the screen without the user knowing.
In this article series I'll try to describe and implement a simple isomorphic router using functional paradigm principles. My experience as a functional API architect isn't extensive, so I consider this an experiment.
I choose router because it's an important and integral part of any modern client-side web application. Most popular frameworks provide their own routers out of the box, but these routers usually aren't simple. I'll try to implement a simple and extendible router at the same time.
I'll use the awesome static type checker, Flow, and all of the new ECMAScript features available in Babel.
Part 1 — Definitions
Before implementation I should define what the router does and the individual router parts. It's really simple — the router transforms some URL into an action which changes the current screen of application. This action is usually called a transition. A transition could be asynchronous if the new screen depends on some data on a server. At the same time, a transition can't return a new screen if the URL isn't valid for it.
type Transition = (url: Url) => Promise<?Screen>
The URL is just a string, but what is a screen? I don't want to depend on any framework or template engine so in my case, a screen could be any object.
type Url = string
type Screen = Object
The router itself is a system that can push notifications about screen changes and can be notified about URL changes. It's very similar to Observable behavior that is implemented in the RxJS library, but I don't want complicate my router, so I just define a simple subscribe API.
type Listener<T> = (value: ?T) => void
type Subscriber<T> = (listener: Listener<T>) => Unsubscriber
type Unsubscriber = () => void
type Router = {
subscribe: Subscriber<Screen>,
navigateTo: (url: Url) => Promise<?Screen>
}
Listener is a function that receives changes, Subscriber is a function that receives a listener and returns the Unsubscriber function. So Router has a function that subscribes on the current screen and a function that notifies the router about the current URL.
Isomorphism imposes a limitation on singletones usage. Also the router should have knowledge about all application transitions. The router constructor solves that problems.
type RouterCreator = (transition: Transition) => Router
You could see that this constructor receives only one transition function. But every single application that needs the router has at least two transitions!
I could do it like in most big routers — write a standard for route definitions, accept array of routes, implement a matching algorithm, or I could provide to a user a lot of abstract classes like Route, RouteCollection, RouteMatchingStrategy, etc. All these variants make code and API more complicated and gives less freedom to configuration. My goal is the opposite — I want to make it all as simple as possible and functional paradigm helps me do that.
Providing a single function into the router releases it from the requirement to implement different algorithms. For the router, that function is a fully prepared constructor of a screen.
Transition is a simple function that's why we can apply to it every functional pattern, including higher-order functions. Using higher-order function, I can compose all application transitions into a single function. I'll describe this pattern in the next part.
That's all for basic router parts. Other definitions will be added later.
Part 2. Higher-order transitions
This part will contain more code than words. I'll start with two transition functions for two screens.
type QueryParameters = {[key: string]: string}
async function indexTransition(url: Url): Promise<?Screen> {
if (url !== '/') {
return
}
const props = await api.fetchIndexData()
return {component: 'IndexPage', props}
}
async function itemTransition(url: Url): Promise<?Screen> {
const queryParameters: ?QueryParameters = matchRoutePattern('/items/:id', url)
if (!queryParameters) {
return
}
const {id} = queryParameters
const props = await api.fetchItemData({id})
return {component: 'ItemPage', props}
}
Pretty easy but I don't want to implement the URL matching in every transition. I've already extracted everything that I could into separate functions, but I can dig deeper. Functions in JavaScript are first-class citizens. This means that I can pass any function as an argument and return one function from another. Using these properties of JavaScript, I can write another function that will create transitions for me.
type TransitionHandler = (queryParameters: QueryParameters) => Promise<Screen>
type TransitionCreator = (pattern: string, handler: TransitionHandler) => Transition
function createTransition(pattern: string, handler: TransitionHandler): Transition {
return async function transition(url: Url): Promise<?Screen> {
const queryParameters: ?QueryParameters = matchRoutePattern(pattern, url)
if (queryParameters) {
return await handler(queryParameters)
}
}
}
Now the transitions have become simpler.
const indexTransition: Transition = createTransition('/',
async function(queryParameters: QueryParameters): Promise<Screen> {
return {
component: 'IndexPage',
props: await api.fetchIndexData()
}
}
)
const itemTransition: Transition = createTransition('/items/:id',
async function({id: string}: QueryParameters): Promise<Screen> {
return {
component: 'ItemPage',
props: await api.fetchItemData({id})
}
}
)
However, how can I pass these two functions into one argument? To do this I'll create one more function that will combine several transitions into a single transition that will in turn invoke them in series.
type TransitionsCombinator = (...transitions: Array<Transition>) => Transition
function combineTransitions(...transitions: Array<Transition>): Transition {
return async function combinedTransition(url: Url): Promise<?Screen> {
const screens: Array<?Screen> =
await* transitions.map(transition => transition(url))
return screens.find(screen => screen !== undefined)
}
}
const allTransitions = combineTransitions(
indexTransition,
itemTransition
)
That's how most routers works. Implementing URL matching and data fetching in one function gives much more freedom to configuration and does't complicate the API.
I'll add one more transition for the screen of the 404 page.
async function notFoundTransition(url: Url): Promise<?Screen> {
return {component: 'NotFoundPage', props: {}}
}
const allTransitions = combineTransitions(
indexTransition,
itemTransition,
notFoundTransition
)
In the same way I can wrap all transitions so as to catch an error that I can use in the screen of the error page.
function createErrorTransition(transition: Transition): Transition {
return function errorTransition(url: Url): Promise<?Screen> {
try {
return await transition(url)
} catch (error) {
return {
component: 'ErrorPage',
props: {error}
}
}
}
}
const allTransitions = createErrorTransition(
combineTransitions(
indexTransition,
itemTransition,
notFoundTransition
)
)
I'll play a little bit more. Most routers have the ability to wrap a group of transitions with some prefix.
type PrefixTransition = (prefix: Url, transition: Transition) => Transition
function prefixTransition(prefix: Url, transition: Transition): Transition {
const prefixRe: RegExp = new RegExp(`^${prefix}`)
return async function prefixedTransition(url: Url): Promise<?Screen> {
return await transition(url.replace(prefixRe, ''))
}
}
const allTransitions = createErrorTransition(
combineTransitions(
prefixTransition(
'/pages',
combineTransitions(
indexTransition,
itemTransition
)
),
notFoundTransition
)
)
To make this possible I've used higher-order functions. I've just wrapped the function to change its behavior, but I left the same interface. That allows me to create a both simple and powerful library API.
In the next parts I'll implement support of the History API, tell you how to use the router on the server side, add redirect support, show you how to easily test all of this, and so on.