A generic approach to URL-based state hydration
Storing state in URL to be used to later hydrate your local state variables is an age-old practice in the frontend ecosystem. While there is a plethora of libraries that let you programmatically access your URL’s various tokens along with providing Typesafety in some cases (React Router being one such example), there is still an initial layer where one needs to grab the variable in question and pass it to the underlying library.
But what happens when your variable is an object? Sure, one could programmatically iterate over the object, search for relevant keys, and then append them one by one in the URL.
But what happens when your variable is an object where the keys could have unknown values and sometimes… unknown keys too?
The following architectural approach aims to solve this very problem — given an unknown length object variable, append its value to the URL and rehydrate the same object programmatically.
+----------------+ +------------------+ +------------------+
| | | | | |
| Object to URL |------>| URL with Object |------>| URL to Object |
| (Serialization)| | String | | (Deserialization)|
| | | | | |
+----------------+ +------------------+ +------------------+
We first need to understand the rough shape of our variable before we move forward — remember this is just an example shape, the same approach can be used for any kind of JS Object.
We’ll take an example of an algebraic application that allows users to put in different operators like and
, or
, not
, equals
etc and put in values to compare against them like a
equals
1
To make this even more complex, let us add a functional requirement that individual expressions also need to be comparable, eg.
(a equals 1) and (b not equals 2)
This leads to an interface of our object that would look something like this —
interface AlgebraicToken {
value: any;
propertyKey: string;
operator: LegalOperators
}
type LegalOperators = "and" | "or" | "equals" | "not equals" ....
interface AlgebraicOperation {
tokens: readyonly AlgebraicToken[];
operation: LegalOperators;
}
AlgebraicOperation
+-----------------+
| - tokens |
| - operation |
+-----------------+
|
| contains
v
AlgebraicToken
+-----------------+
| - value |
| - propertyKey |
| - operator |
+-----------------+
The AlgebraicOperation
would store the whole operation to be performed whereas AlgebraicToken
would store individual operations.
Remember — our goal here is to not have individual keys for the object like algebraicToken1Value=123;....algebraicTokenNValue=abc
and have to manage them individually, because, at hydration time, we wouldn't know the value of N (i.e. how many tokens exist)
As a result, we need to devise something that could store everything in one go — and retrieve it in one go as well.
We start by defining two things — a constant for the URL Key which we’d use to store our entire object alongside another constant for separating our tokens.
const TOKEN_KEY_VALUE_SEPERATOR = ":"
const TOKEN_VALUES_SEPERATOR = ",";
const ALGEBRAIC_OPERATION_SERPATOR = "_"
const TOKEN_KEY = "expression"
With this out of the way, we start building our first part of the architecture — a function that takes in the Object and converts it to a string that can be appended to the URL.
export const convertAlgebraicTokensToString = (tokens: readyonly AlgebraicToken[]): string => {
const outputString = tokens
.map(token => sanitizeAmpersandAndEquals(createSearchParams(token)).toString())
.join(ALGEBRAIC_OPERATION_SERPATOR);
return outputString;
}
Let us break this function down step by step
+-------------------+ +------------------------+ +----------------------+
| | | | | |
| Array of Tokens |---->| createSearchParams |---->| sanitizeAmpersandAnd |
| | | | | Equals |
+-------------------+ +------------------------+ +----------------------+
|
v
+-------------------+
| |
| URL-safe String |
| |
+-------------------+
- createSearchParams — this function is given by
react-router-dom
that behaves somewhat similar toURLSearchParams
except it also supports arrays as values in the object form of the initializer instead of just strings. - sanitizeAmpersandAndEquals — this function simply takes in the URLQueryString given by
createSearchParams
and splits out a new string where&
is replaced byTOKEN_VALUES_SEPERATOR
and=
is replaced by theTOKEN_KEY_VALUES_SEPERATOR
— the choices of replacement here are arbitrary however one does need to replace any & and = because they might cause issues when it comes to parsing the string. - Finally, we perform the .join of various tokens using the separator we previously defined
We also define one extra constant in the URL to store the value of the overall operation in the `AlgebraicOperation` interface
This allows us to iteratively parse the object's tokens, take everything, and convert them into a safe string that can be injected in the URL — this was the easy part.
This is how the URL would look at the end of this process
URL: http://example.com?expression=t1:v1,t2:v2_t3:v3
Where:
- expression is the TOKEN_KEY
- t1, t2, t3 are token keys
- v1, v2, v3 are token values
- : is the TOKEN_KEY_VALUE_SEPARATOR
- , is the TOKEN_VALUES_SEPARATOR
- _ is the ALGEBRAIC_OPERATION_SEPARATOR
Now comes the question, how do we parse a string to return that same object when we come to rehydration?
Rehydration
+-----------------+ +-----------------------+ +---------------------+
| | | | | |
| URL String |---->| Split by Operation |---->| Split by Token |
| | | Separator | | Separator |
+-----------------+ +-----------------------+ +---------------------+
|
v
+---------------------+
| |
| Reconstruct Tokens |
| |
+---------------------+
We define the parsing function, that takes in an inputString
and outputs an `AlgebraicToken[]`
export const parseURLToAlgebraicTokens = (inputString: string): AlgebraicToken[] => {
// We start off doing the reverse, the last step in convertAlgebraicTokensToString
// was to join individual tokens using this operator, so now we split them back
// into an array
const baseKeyValuePairs = inputString.split(ALGEBRAIC_OPERATION_SERPATOR);
const result:AlgebraicToken[] = [];
//Iterate over inidivudal tokens
for(const pair of baseKeyValuePairs){
// We now break the object down into individual components.
const atomicComponents = pair.split(TOKEN_VALUES_SEPERATOR);
const token: AlgebraicToken = {};
for(const atom of atomicComponents) {
const [key, value] = atom.split(TOKEN_KEY_VALUE_SEPERATOR);
//You can chose to iterate over individual keys, and perform security based sanitisation in this step
token[key] = value;
}
// You can choose to now validate the object's completness in this step
result.push(token);
}
return result;
}
Notice — there are two steps here, where further intervention can be added to ensure security (checking for malicious values before appending token[key]=value and a second completeness check that can be introduced when performing result.push(..)
Finally, we add the operation
value we had previously appended separately to the URL in the result object returned to us — and we’d have our complete object formed back for us.
Notice, the only “custom” code in this solution is to ensure tokens are stored validly, we are never accessing individual object properties anywhere, neither during generation nor during parsing before re-hydration, allowing us to store and rehydrate using pretty much any object type.
Some downsides to this approach (because, trade-offs 🤷🏼)
- Sanitization steps will require the function to have “knowledge” of the properties of the object since each property might have a valid set of parameters.
- Separators are still needed, and depending on your use case, the number of separators might get out of hand fairly quickly.
- The URL is human-readable — albeit this one can be both a pro and a con depending on your use case.