What?
I know what you're thinking - "State management on a server? Shouldn't servers be stateless?"
Today, I'm going to jump through a few use-cases for having server-side state.
Why?
We've been taught that servers, in general, should be stateless, and that anything your server needs should be stored in a database or a config file.
What happens if you want to dynamically control these? For example, let's say you have some scheduled tasks running in your app. You could potentially use Cron (or one of the Cron libraries for a code-based solution). If you want to change them, that's either updating a config, or doing a new deploy with new code.
Likewise, if you want to manage which GraphQL resolvers are enabled, you'd have to go through the same process.
With the rise of services like DigitalOcean's App Platform, neither of these are ideal solutions as they either require changing the config inside a docker container (requiring reconfiguring each time you redeploy), or they require you to redeploy, using some of those crucial build minutes and fixing up any issues that might crop up.
What if I told you there was an easier way? For this discussion, I'm going to be using FaunaDB, combined with MobX 6.0 to create dynamic config management in a Node server. I won't be covering FaunaDB integration in this article, as you could use any database solution, or even just have a remote file with your config stored.
How?
For this example, I'm going to use the scheduled tasks configuration. My use case is retrieving tweets from the National Rail enquiries Twitter account, which provides train delay information in the UK.
However, I only want to run that task if I've enabled it. Retrieving tweets is a typical use case for many applications, but consider it as just an example for the sake of this article.
The first thing to do is create a MobX store. This is just a class, with some properties which are marked as @observable
, an @action
to update the state, and some @computed
getters to retrieve single fields from my state:
import logger from "@Modules/Logging/logging.module";
import { observable, computed, action, makeAutoObservable } from "mobx";
import { IFaunaDbEnvironmentScheduledTask } from "./interfaces";
export enum EScheduledTask {
fetch_and_import_national_rail_delay_tweets = "fetch_and_import_national_rail_delay_tweets",
}
export type RScheduledTask = Record<EScheduledTask, IFaunaDbEnvironmentScheduledTask>;
export interface IFaunaDbEnvironmentConfig {
myapi: {
scheduled_tasks: RScheduledTask;
};
}
class EnvironmentConfigStore {
constructor() {
makeAutoObservable(this);
}
@observable public EnvironmentConfig: IFaunaDbEnvironmentConfig = null;
@action public setConfig = (config: IFaunaDbEnvironmentConfig) => {
logger.log("debug", `Server config loaded to store successfully!`);
this.EnvironmentConfig = config;
};
@computed public get scheduledTasks() {
return this?.EnvironmentConfig?.myapi?.scheduled_tasks;
}
}
const EnvironmentConfig = new EnvironmentConfigStore();
export default EnvironmentConfig;
As you can see, I've defined an interface for my state (which matches the structure of a document stored in FaunaDB), created a state store class, and decorated my properties. This is all fairly standard for MobX. I've also used makeAutoObservable
in my constructor. I've also got a logger.log
call in there - this is just a standard Winston logger class.
The next step is to use a MobX reaction
to monitor my scheduled task. I do this in a separate file because writing modular code is something that you should try to do where possible:
import { reaction } from "mobx";
import EnvironmentConfigStore from "@Stores/store.environmentConfig";
import logger from "@Modules/Logging/logging.module";
let timer: NodeJS.Timeout = null;
const disableTimer = () => {
clearInterval(timer);
};
// Check if the task is enabled
// Disables timer if not
reaction(
() => EnvironmentConfigStore.scheduledTasks.fetch_and_import_national_rail_delay_tweets.enabled,
enabled => {
logger.log("debug", `fetch_and_import_national_rail_delay_tweets is now ${enabled ? "enabled" : "disabled"}!`);
if (enabled === false) {
disableTimer();
} else {
timer = setInterval(() => {
console.log("Task would run now!");
}, EnvironmentConfigStore.scheduledTasks.fetch_and_import_national_rail_delay_tweets.frequency_in_ms);
}
},
{
fireImmediately: true,
onError: error => {
console.log("Error in reaction: ", error);
}
}
);
What we're doing here is creating a reaction
which will trigger each time the scheduledTasks.fetch_and_import_national_rail_delay_tweets.enabled
property changes.
If the property changes to enabled: false
, we stop our timer, otherwise, we start our timer. You can see that I currently only have a console.log("Task would run now!")
as my function for the timer, but you can do whatever you wish to do in there.
Since the reaction only runs when the value changes, the timer will only be created when the value is set to true
, and only cleared if the value changes to false
- to clarify: You will not have multiple timers running if you use reaction
in this way.
The final step is to get the config from FaunaDB, and update the store:
import EnvironmentConfigStore from "@Modules/Stores/store.environmentConfig";
doSomethingThatRetrievesConfig().then(myConfig => {
EnvironmentConfigStore.setConfig(myConfig)
});
In this example, I retrieve the config from FaunaDB and then update the store. You could run this in a timer to retrieve it every so often, or you could subscribe to the document instead - the process is the same in either case.
That's all there is to it. Whenever I update the document which contains my server config on FaunaDB, this is propagated to the store, which then handles enabling or disabling the timer for the scheduled task.
You can integrate this in any way that feels right for your codebase.
Other use cases
There's potentially unlimited use cases for this. Here's just a few:
- Dynamically enabling or disabling GraphQL resolvers
- Marking a server as production, staging, local, etc
- Enabling or disabling access to routes dynamically
Final notes
If you want to be able to configure your server at runtime, and serverless isn't a suitable use case for your project, then having some sort of state management becomes necessary. The beauty of this method is that it works with any database system. You could potentially just store the config in a file somewhere and periodically retrieve that file instead, but you've got to ensure you have the security you need around it.
To reiterate, my use case was based on DigitalOcean App Platform, and I wanted an easy way to manage scheduled tasks (amongst some other server config, which isn't covered here).
Top comments (0)