JavaScript Functions That Run Beautifully on Client and Server
David Lynch
Full-Stack Software Engineer - JavaScript React Redux Meteor Node.js MongoDB - Architect - Project Lead
One of the compelling benefits of Node.js is that a single language, JavaScript, can be used on client and server. Developers use the word isomorphic to describe JavaScript code that has been written to run anywhere. To make that possible, your JavaScript functions must adhere to certain coding conventions.
After some experimentation and false starts, we have devised helpful programming standards that foster isomorphic code. I will share these standards with you through concrete examples.
Consider Pract.us, our flagship SaaS application. Pract.us uses a variety of third-party packages, plus some custom-written UI code to deal with forms, animations and so on. In contrast to this “mechanical” code, which has nothing to do with the problem domain, the system has dozens of domain functions which are germane exclusively to Pract.us. Many of these domain functions can be classified either as queries or transactions because they either read from or write to the database.
Domain functions are the heart of the system. They encapsulate logic that updates the database in response to UI events. A large part of the Pract.us development effort goes into writing and refining these domain functions.
In Pract.us, a UI event handler will usually delegate to one or more domain functions. The domain function(s) will perform a number of database queries and updates, and some of them are complex, involving updates to several collections.
Pract.us domain functions are anchored in a global JavaScript object named Practus. Because Pract.us is a small system, it is feasible to anchor all domain functions in Practus.
Domain functions are technically stateless because they have no instance variables. In a Meteor project, such functions operate exclusively on passed parameter values and data stored in Mini-Mongo and/or MongoDB.
To facilitate isomorphism, we first declare a Practus anchor object in a project folder that is shared by both client and server. In total, we create three files named practus.js:
- client (functions visible to client only)
- server (functions visible to server only)
- shared (functions visible to both client and server)
By using the Underscore extend function, we can append functions to the Practus shared object. Since shared functions are visible to both client and server, this results in the following arrangement:
- Client-side Practus object = client functions + shared functions
- Server-side Practus object = server functions + shared functions
Technically, only shared functions need to be isomorphic, but whenever possible, we apply the same programming conventions to all domain functions. This allows us to move functions around without costly refactoring.
To invoke a domain function, we simply make a call on the Practus object. Example:
Practus.setCardOpen(cardId, true)
Since client, server and shared functions are all invoked via the Practus object, the impact of moving functions is minimized. This is particularly helpful for nested calls (i.e., when one Practus function calls another Practus function).
When you write domain functions, you must restrict your code to use only those services that are available on both client and server. A client-side event handler may use jQuery to extract information from the DOM, then pass that information as parameters to isomorphic domain functions; however, any jQuery calls must be performed outside the domain function, because jQuery calls inside the domain function would prevent that function from being used on the server side.
In Pract.us, domain functions always return a result object that contains a success/failure indicator, an i18n message key, message variables and a message severity level. Domain functions never throw errors, but instead catch-and-return them as failure-type result objects. The UI will render the response object to the user via PNotify, resulting in a pop-up message in the lower-right corner of the page:
PNotify can be used to report results that are returned from both client-side and server-side domain functions:
- When the UI calls a client-side domain function, that function will return the result object directly.
- When the UI calls a server-side domain function, that function will return the result object via a callback.
PNotify is spectacularly well-suited for reporting results from domain functions because it can work both synchronously and asynchronously. When the UI invokes server-side domain functions, there will be a delay before the results come back. Using PNotify, even if that delay is long, and the user has moved on to a new page, the message will be displayed properly once it arrives; thus, the UI can call server-side domain functions without locking the UI and forcing the user to wait for the response.
A well-written isomorphic function can be moved from client to server or vice-versa with negligible impact on user experience; the only difference will be latency.
In a Meteor project, Mini-Mongo makes isomorphism feasible because it provides a client-side API that is identical to the server-side MongoDB API. In a data-centric system, most domain functions either query or update the database. Since Meteor provides identical APIs on both client and server, most domain functions can be written isomorphically.
Although domain functions can run equally well on client or server, there can be tantalizing advantages to running them on the client. First, there are UI performance advantages. UI events, such as toggling a switch, will trigger an immediate UI update as Meteor responds to Mini-Mongo state changes and re-renders the DOM locally without a server round trip (Meteor developers call this latency compensation). Moreover, client-centric coding can reduce the server-side workload and costs to a level that would be difficult to achieve with a traditional page-oriented application.
When you write isomorphic functions, it will make things easier if you use Mini-Mongo and MongoDB to hold all of your state. You must publish and subscribe collections carefully so that all necessary data is available in Mini-Mongo before attempting to promote server-side functions to the client. On occasion, I’ve tried to move functions from server to client only to discover that some of the data that those functions needed had not been published to the client. Such problems can be remedied by refactoring domain functions or changing the publishing rules. Domain functions may be split so that part of the work is done on the client, while other parts remain on the server.
With good programming standards and proper design, you can develop your domain functions isomorphically, allowing you to easily move code from client to server or vice-versa as you see fit. The option to move functions with impunity can improve quality and performance, while simultaneously reducing development and operational costs.