\') || SELF_CLOSING_TAGS.has(tagName);\\n const tagAttributeString = str.\\n replace(new RegExp(`^</?${tagName}`), \'\').\\n replace(/\\\\/?>/, \'\').\\n trim() || null;\\n\\n tokens.push({\\n type: \'tag\',\\n tagName,\\n opening: !isClosingTag || isSelfClosingTag,\\n closing: isClosingTag || isSelfClosingTag,\\n tagAttributeString\\n });\\n\\n buffer = \'\';\\n};\\n I\'m not going to dive deeper into attribute handling, as this is almost a tokenizer of its own, but we\'ll simply store them as a string for now. Additionally, my self-closing tags list is very incomplete, but it\'ll do for now.
While this might look a little intimidating at first, there\'s not a whole lot going on in this function. We\'re simply performing the steps I outlined above, then, if all goes well, we\'re adding the token to the tokens
array and emptying the buffer.
We\'re now ready to tokenize an HTML string. The structure of the tokenizer is very similar to the previous article, so I won\'t bother you with all the minute details. The only point I would like to focus on is the actual character processing loop.
\\nIn this loop, we\'re simply adding characters to the buffer until we encounter either a <
or a >
. When we do, we flush the buffer and continue. The one thing that\'s of note is that we handle these two characters differently. When we encounter a <
, we flush the buffer, then add the character, whereas when we encounter a >
, we add the character, then flush the buffer. This allows us to parse both tags and text nodes correctly.
Let\'s take a look at the full tokenization code:
\\nconst SELF_CLOSING_TAGS = new Set([\\n \'br\', \'img\', \'input\', \'meta\', \'hr\', \'link\'\\n]);\\n\\nconst tokenizeHtml = str => {\\n const tokens = [];\\n let buffer = \'\';\\n\\n const processTagToken = str => {\\n if (!str.match(/^<[^<>]+>$/))\\n throw new Error(`${str} is not a valid HTML tag`);\\n\\n const tagName = str.match(/^<\\\\/?([^<>/ ]+)/)[1];\\n const isClosingTag = str.startsWith(\'</\');\\n const isSelfClosingTag =\\n str.endsWith(\'/>\') || SELF_CLOSING_TAGS.has(tagName);\\n const tagAttributeString = str.\\n replace(new RegExp(`^</?${tagName}`), \'\').\\n replace(/\\\\/?>/, \'\').\\n trim() || null;\\n\\n tokens.push({\\n type: \'tag\',\\n tagName,\\n opening: !isClosingTag || isSelfClosingTag,\\n closing: isClosingTag || isSelfClosingTag,\\n tagAttributeString\\n });\\n\\n buffer = \'\';\\n };\\n\\n const processTextToken = str => {\\n tokens.push({ type: \'text\', value: str });\\n buffer = \'\';\\n };\\n\\n // Flush the buffer and process the tokens\\n const flushBuffer = () => {\\n if (!buffer.length) return;\\n\\n const value = buffer.trim();\\n if (value.startsWith(\'<\') || value.endsWith(\'>\'))\\n processTagToken(value);\\n else\\n processTextToken(value);\\n };\\n\\n // Tokenize the input string\\n [...str].forEach(char => {\\n // If we encounter the opening tag, flush the buffer\\n if (char === \'<\') flushBuffer();\\n\\n // Add the character to the buffer\\n buffer += char;\\n\\n // If we encounter the closing tag, flush the buffer\\n if (char === \'>\') flushBuffer();\\n });\\n\\n // Flush any remaining buffer\\n flushBuffer();\\n\\n return tokens;\\n};\\n
Let\'s see it in action, shall we?
\\nconst tokens = tokenizeHtml(\\n \'<div class=\\"container\\"><p>Hello, <strong>world</strong>!<br/></p></div>\'\\n);\\n// [\\n// {\\n// type: \'tag\', tagName: \'div\',\\n// opening: true, closing: false,\\n// tagAttributeString: \'class=\\"container\\"\'\\n// },\\n// {\\n// type: \'tag\', tagName: \'p\',\\n// opening: true, closing: false,\\n// tagAttributeString: null\\n// },\\n// { type: \'text\', value: \'Hello,\' },\\n// {\\n// type: \'tag\', tagName: \'strong\',\\n// opening: true, closing: false,\\n// tagAttributeString: null\\n// },\\n// { type: \'text\', value: \'world\' },\\n// {\\n// type: \'tag\', tagName: \'strong\',\\n// opening: false, closing: true,\\n// tagAttributeString: null\\n// },\\n// { type: \'text\', value: \'!\' },\\n// {\\n// type: \'tag\',\\n// tagName: \'br\',\\n// opening: true, closing: true,\\n// tagAttributeString: null\\n// },\\n// {\\n// type: \'tag\', tagName: \'p\',\\n// opening: false, closing: true,\\n// tagAttributeString: null\\n// },\\n// {\\n// type: \'tag\', tagName: \'div\',\\n// opening: false, closing: true,\\n// tagAttributeString: null\\n// }\\n// ]\\n
In the previous article on bracket pair matching, we used a simple stack-based approach to match bracket pairs. This scenario is no different, only we need to work with tokens, instead of a raw string and its characters.
\\nThe idea is rather simple. We skip non-tag tokens entirely and we keep track of encountered tags. When we encounter an opening tag, we push its tagName
and index to the stack. When we encounter a closing tag, we pop the last tag from the stack and check if they match. If they don\'t, we throw an error. If they do, we continue. If we reach the end of the tokens and the stack is not empty, we also throw an error.
const findMatchingTags = tokens => {\\n const { pairs, stack } = tokens.reduce(\\n ({ pairs, stack }, token, i) => {\\n if (token.type === \'tag\') {\\n if (token.opening && !token.closing) {\\n stack.push({ index: i, tagName: token.tagName });\\n } else if (token.closing && !token.opening) {\\n const {index: openingIndex, tagName} = stack.pop();\\n if (tagName !== token.tagName)\\n throw new Error(`Mismatched tags: ${tagName} and ${token.tagName}`);\\n pairs.set(openingIndex, i);\\n pairs.set(i, openingIndex);\\n }\\n }\\n return { pairs, stack };\\n },\\n { pairs: new Map(), stack: [] }\\n );\\n\\n if (stack.length)\\n throw new Error(\'Unmatched HTML tags\');\\n\\n return pairs;\\n};\\n
This isn\'t all that different from the last implementation, only we\'re working with tokens instead of characters. Let\'s see it in action:
\\n// Given the tokens from the previous example\\nconst pairs = findMatchingTags(tokens);\\n// Map {\\n// 0 => 9, 9 => 0,\\n// 1 => 8, 8 => 1,\\n// 3 => 5, 5 => 3\\n// }\\n
Putting the previous two pieces together, we can create a simple HTML validator that tokenizes the input string, matches the tag pairs and throws an error if the tags are mismatched or unmatched or if the input string is not a valid HTML string.
\\nconst validateHtml = str => {\\n const tokens = tokenizeHtml(str);\\n const matchingTags = findMatchingTags(tokens);\\n return { tokens, matchingTags };\\n};\\n
And that\'s basically our simple HTML validator done! Of course, there are a ton more things one can and should take care of when parsing HTML, but this is a good starting point for understanding the basics.
\\nI hope this article has really driven home the mentality of tokenization and how it can be used in real-world scenarios to break down complex strings into more manageable pieces. See you in the next one!
Last updated: February 21, 2025 View on GitHub
","description":"I\'ve been down the tokenization rabbit hole for a little while now, if it wasn\'t obvious from the previous articles on bracket pair matching and math expression tokenization. This time, I wanted to try something a little more complex, but still simple enough to be done in a…","guid":"https://www.30secondsofcode.org/js/s/simple-html-tokenization-validation","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-02-20T16:00:00.280Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/jars-on-shelf-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/jars-on-shelf-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," String "],"attachments":null,"extra":null,"language":null},{"title":"Create a math expression tokenizer in JavaScript","url":"https://www.30secondsofcode.org/js/s/math-expression-tokenizer","content":"In the previous article on Reverse Polish Notation, I skimmed over a pretty important detail, that of math expression tokenization. I thought I\'d tackle this problem on its own, as it may help you understand the fundamental logic of building more complex interpreters and compilers.
\\nThis article is primarily a learning resource. If you plan on using this in production, it\'s probably a good idea to use a battle-tested library. I won\'t give any recommendations, as I\'ve never used one myself, but a quick search should yield some good results.
\\nIn the context of a math expression, a token is a single unit of the expression. It can be a number, an operator, a parenthesis, or any other symbol that makes up the expression. For example, in the expression 3 + 4
, the tokens are 3
, +
, and 4
. Tokens can span multiple characters, such as floating-point numbers, multi-character operators, or even function names.
A tokenizer is a function that takes a math expression as input and returns an array of tokens. Instead of going all in, our tokenizer will be built in smaller iterations. We\'ll start with integers and operators, then add support for parentheses and floating-point numbers and, finally, negative numbers.
\\nOur goal is to be able to tokenize the expression below and get the following tokens:
\\ntokenize(\'((1 + 20) * -3.14) /9\');\\n// [\\n// { type: \'PAREN_OPEN\' },\\n// { type: \'PAREN_OPEN\' },\\n// { type: \'NUMBER\', value: 1 },\\n// { type: \'OP_ADD\' },\\n// { type: \'NUMBER\', value: 20 },\\n// { type: \'PAREN_CLOSE\' },\\n// { type: \'OP_MULTIPLY\' },\\n// { type: \'NUMBER\', value: -3.14 },\\n// { type: \'PAREN_CLOSE\' },\\n// { type: \'OP_DIVIDE\' },\\n// { type: \'NUMBER\', value: 9 }\\n// ]\\n
Why this expression? Because it contains a multitude of different tokens and edge cases that we\'ll need to learn to handle properly.
\\nAt its most basic form, a tokenizer reads an input (in this case a string) one character at a time and groups them into tokens. We\'ll start as simple as possible, implementing the tokenization of integers and operators.
\\nconst TOKEN_TYPES = {\\n NUMBER: \'NUMBER\',\\n OPERATOR: {\\n \'+\': \'OP_ADD\',\\n \'-\': \'OP_SUBTRACT\',\\n \'*\': \'OP_MULTIPLY\',\\n \'/\': \'OP_DIVIDE\',\\n }\\n};\\n\\nconst OPERATORS = Object.keys(TOKEN_TYPES.OPERATOR);\\n\\nconst tokenize = expression => {\\n const tokens = [];\\n let buffer = \'\';\\n\\n // Flush the buffer, if it contains a valid number token\\n const flushBuffer = () => {\\n if (!buffer.length) return;\\n\\n const bufferValue = Number.parseFloat(buffer);\\n if (Number.isNaN(bufferValue)) return;\\n\\n tokens.push({ type: TOKEN_TYPES.NUMBER, value: bufferValue });\\n buffer = \'\';\\n };\\n\\n [...expression].forEach(char => {\\n // Skip whitespace\\n if (char === \' \') {\\n flushBuffer();\\n return;\\n }\\n\\n // If the character is a number, add it to the buffer\\n if (!Number.isNaN(Number.parseInt(char))) {\\n buffer += char;\\n return;\\n }\\n\\n // If the character is an operator, flush the buffer, add the operator token\\n if (OPERATORS.includes(char)) {\\n flushBuffer();\\n tokens.push({ type: TOKEN_TYPES.OPERATOR[char] });\\n return;\\n }\\n\\n // If we reach here, it\'s an invalid character\\n throw new Error(`Invalid character: ${char}`);\\n });\\n\\n // Flush any remaining buffer\\n flushBuffer();\\n\\n return tokens;\\n};\\n
As you can see, the algorithm is based on a simple Array.prototype.forEach()
that iterates over each character in the input expression. We add numbers to a buffer, and flush the buffer when we encounter an operator or a whitespace character. If we encounter an invalid character, we throw an error.
Let\'s test the tokenizer with a simple expression:
\\ntokenize(\'3 + 4\');\\n// [\\n// { type: \'NUMBER\', value: 3 },\\n// { type: \'OP_ADD\' },\\n// { type: \'NUMBER\', value: 4 }\\n// ]\\n
Our current implementation will throw an error if there\'s even a single unknown character. The .
character, used for floating point numbers, will trigger this error, too.
tokenize(\'3.14 + 4\');\\n// Error: Invalid character: .\\n
Thus, in order to handle floating point numbers, we\'ll need to modify the tokenizer a little bit to allow for a single .
character in the buffer.
const tokenize = expression => {\\n const tokens = [];\\n let buffer = \'\';\\n\\n // Flush the buffer, if it contains a valid number token\\n const flushBuffer = () => {\\n if (!buffer.length) return;\\n\\n const bufferValue = Number.parseFloat(buffer);\\n if (Number.isNaN(bufferValue)) return;\\n\\n tokens.push({ type: TOKEN_TYPES.NUMBER, value: bufferValue });\\n buffer = \'\';\\n };\\n\\n [...expression].forEach(char => {\\n // Skip whitespace\\n if (char === \' \') {\\n flushBuffer();\\n return;\\n }\\n\\n // If the character is a number, add it to the buffer\\n if (!Number.isNaN(Number.parseInt(char))) {\\n buffer += char;\\n return;\\n }\\n\\n // If the character is a floating point, only add it if the buffer\\n // doesn\'t already contain one, otherwise it\'s an invalid character\\n if (char === \'.\' && !buffer.includes(\'.\')) {\\n buffer += char;\\n return;\\n }\\n\\n // If the character is an operator, flush the buffer, add the operator token\\n if (OPERATORS.includes(char)) {\\n flushBuffer();\\n tokens.push({ type: TOKEN_TYPES.OPERATOR[char] });\\n return;\\n }\\n\\n // If we reach here, it\'s an invalid character\\n throw new Error(`Invalid character: ${char}`);\\n });\\n\\n // Flush any remaining buffer\\n flushBuffer();\\n\\n return tokens;\\n};\\n
Now, the tokenizer will correctly handle floating point numbers, but throw an error if there are multiple .
characters in the buffer (same number).
tokenize(\'3.14 + 4\');\\n// [\\n// { type: \'NUMBER\', value: 3.14 },\\n// { type: \'OP_ADD\' },\\n// { type: \'NUMBER\', value: 4 }\\n// ]\\n\\ntokenize(\'3.14.15 + 4\');\\n// Error: Invalid character: .\\n
The next step is to add support for parentheses. We\'ll treat them as separate tokens, as they\'re used to group expressions. We\'ll not be adding any validation for now, but you can read more about this problem in the article about bracket pair matching.
\\nconst TOKEN_TYPES = {\\n NUMBER: \'NUMBER\',\\n OPERATOR: {\\n \'+\': \'OP_ADD\',\\n \'-\': \'OP_SUBTRACT\',\\n \'*\': \'OP_MULTIPLY\',\\n \'/\': \'OP_DIVIDE\',\\n },\\n PARENTHESIS: {\\n \'(\': \'PAREN_OPEN\',\\n \')\': \'PAREN_CLOSE\',\\n }\\n};\\n\\nconst OPERATORS = Object.keys(TOKEN_TYPES.OPERATOR);\\nconst PARENTHESES = Object.keys(TOKEN_TYPES.PARENTHESIS);\\n\\nconst tokenize = expression => {\\n const tokens = [];\\n let buffer = \'\';\\n\\n // Flush the buffer, if it contains a valid number token\\n const flushBuffer = () => {\\n if (!buffer.length) return;\\n\\n const bufferValue = Number.parseFloat(buffer);\\n if (Number.isNaN(bufferValue)) return;\\n\\n tokens.push({ type: TOKEN_TYPES.NUMBER, value: bufferValue });\\n buffer = \'\';\\n };\\n\\n [...expression].forEach(char => {\\n // Skip whitespace\\n if (char === \' \') {\\n flushBuffer();\\n return;\\n }\\n\\n // If the character is a number, add it to the buffer\\n if (!Number.isNaN(Number.parseInt(char))) {\\n buffer += char;\\n return;\\n }\\n\\n // If the character is a floating point, only add it if the buffer\\n // doesn\'t already contain one, otherwise it\'s an invalid character\\n if (char === \'.\' && !buffer.includes(\'.\')) {\\n buffer += char;\\n return;\\n }\\n\\n // If the character is an operator, flush the buffer, add the operator token\\n if (OPERATORS.includes(char)) {\\n flushBuffer();\\n tokens.push({ type: TOKEN_TYPES.OPERATOR[char] });\\n return;\\n }\\n\\n // If the character is a parenthesis, flush the buffer,\\n // add the parenthesis token\\n if (PARENTHESES.includes(char)) {\\n flushBuffer();\\n tokens.push({ type: TOKEN_TYPES.PARENTHESIS[char] });\\n return;\\n }\\n\\n // If we reach here, it\'s an invalid character\\n throw new Error(`Invalid character: ${char}`);\\n });\\n\\n // Flush any remaining buffer\\n flushBuffer();\\n\\n return tokens;\\n};\\n
Now, our tokenizer correctly handles parentheses, but won\'t validate if they\'re paired.
\\ntokenize(\'(3 + 4) * 5\');\\n// [\\n// { type: \'PAREN_OPEN\' },\\n// { type: \'NUMBER\', value: 3 },\\n// { type: \'OP_ADD\' },\\n// { type: \'NUMBER\', value: 4 },\\n// { type: \'PAREN_CLOSE\' },\\n// { type: \'OP_MULTIPLY\' },\\n// { type: \'NUMBER\', value: 5 }\\n// ]\\n\\ntokenize(\'((3 + 4)\');\\n// [\\n// { type: \'PAREN_OPEN\' },\\n// { type: \'PAREN_OPEN\' },\\n// { type: \'NUMBER\', value: 3 },\\n// { type: \'OP_ADD\' },\\n// { type: \'NUMBER\', value: 4 }\\n// { type: \'PAREN_CLOSE\' }\\n// ]\\n
Up until this point, we\'ve only been handling positive numbers. To support negative numbers, we need to differentiate between a subtraction operator and a negative sign. We\'ll do this by checking if the -
character is preceded by an operator, a parenthesis, or the start of the expression.
const TOKEN_TYPES = {\\n NUMBER: \'NUMBER\',\\n OPERATOR: {\\n \'+\': \'OP_ADD\',\\n \'-\': \'OP_SUBTRACT\',\\n \'*\': \'OP_MULTIPLY\',\\n \'/\': \'OP_DIVIDE\',\\n },\\n PARENTHESIS: {\\n \'(\': \'PAREN_OPEN\',\\n \')\': \'PAREN_CLOSE\',\\n }\\n};\\n\\nconst OPERATORS = Object.keys(TOKEN_TYPES.OPERATOR);\\nconst OPERATOR_TYPES = Object.values(TOKEN_TYPES.OPERATOR);\\nconst PARENTHESES = Object.keys(TOKEN_TYPES.PARENTHESIS);\\nconst PARENTHESIS_TYPES = Object.values(TOKEN_TYPES.PARENTHESIS);\\n\\nconst tokenIsOperator = token =>\\n token && OPERATOR_TYPES.includes(token.type);\\nconst tokenIsParenthesis = token =>\\n token && PARENTHESIS_TYPES.includes(token.type);\\n\\nconst tokenize = expression => {\\n const tokens = [];\\n let buffer = \'\';\\n\\n // Flush the buffer, if it contains a valid number token\\n const flushBuffer = () => {\\n if (!buffer.length) return;\\n\\n const bufferValue = Number.parseFloat(buffer);\\n if (Number.isNaN(bufferValue)) return;\\n\\n tokens.push({ type: TOKEN_TYPES.NUMBER, value: bufferValue });\\n buffer = \'\';\\n };\\n\\n [...expression].forEach((char) => {\\n // Skip whitespace\\n if (char === \' \') {\\n flushBuffer();\\n return;\\n }\\n\\n // If the character is a number, add it to the buffer\\n if (!Number.isNaN(Number.parseInt(char))) {\\n buffer += char;\\n return;\\n }\\n\\n // If the character is a floating point, only add it if the buffer\\n // doesn\'t already contain one, otherwise it\'s an invalid character\\n if (char === \'.\' && !buffer.includes(\'.\')) {\\n buffer += char;\\n return;\\n }\\n\\n // If the character is a negative sign, flush the buffer, check if it\'s\\n // a negative number (first token or after an operator/parenthesis)\\n if (char === \'-\') {\\n flushBuffer();\\n const lastToken = tokens[tokens.length - 1];\\n\\n if (\\n !lastToken ||\\n tokenIsOperator(lastToken) ||\\n tokenIsParenthesis(lastToken)\\n ) {\\n buffer += char;\\n return;\\n }\\n }\\n\\n // If the character is an operator, flush the buffer, add the operator token\\n if (OPERATORS.includes(char)) {\\n flushBuffer();\\n tokens.push({ type: TOKEN_TYPES.OPERATOR[char] });\\n return;\\n }\\n\\n // If the character is a parenthesis, flush the buffer,\\n // add the parenthesis token\\n if (PARENTHESES.includes(char)) {\\n flushBuffer();\\n tokens.push({ type: TOKEN_TYPES.PARENTHESIS[char] });\\n return;\\n }\\n\\n // If we reach here, it\'s an invalid character\\n throw new Error(`Invalid character: ${char}`);\\n });\\n\\n // Flush any remaining buffer\\n flushBuffer();\\n\\n return tokens;\\n};\\n
Notice that the check for the negative sign may result in the lastToken
indicating it\'s not a negative sign and continue to the operator check. This is intentional, so that both cases can be handled without problems.
While the logic for the negative numbers may seem a little convoluted, here are all the sample expressions I could think of, each with a different edge case:
\\ntokenize(\'-3 + 4\');\\n// [\\n// { type: \'NUMBER\', value: -3 },\\n// { type: \'OP_ADD\' },\\n// { type: \'NUMBER\', value: 4 }\\n// ]\\n\\ntokenize(\' -3-4\');\\n// [\\n// { type: \'NUMBER\', value: -3 },\\n// { type: \'OP_SUBTRACT\' },\\n// { type: \'NUMBER\', value: 4 }\\n// ]\\n\\ntokenize(\'3 + -4\');\\n// [\\n// { type: \'NUMBER\', value: 3 },\\n// { type: \'OP_ADD\' },\\n// { type: \'NUMBER\', value: -4 }\\n// ]\\n\\ntokenize(\'3 * (-4 + 5)\');\\n// [\\n// { type: \'NUMBER\', value: 3 },\\n// { type: \'OP_MULTIPLY\' },\\n// { type: \'PAREN_OPEN\' },\\n// { type: \'NUMBER\', value: -4 },\\n// { type: \'OP_ADD\' },\\n// { type: \'NUMBER\', value: 5 },\\n// { type: \'PAREN_CLOSE\' }\\n// ]\\n
Now, we can finally tokenize the complex math expression we started with:
\\ntokenize(\'((1 + 20) * -3.14) /9\');\\n// [\\n// { type: \'PAREN_OPEN\' },\\n// { type: \'PAREN_OPEN\' },\\n// { type: \'NUMBER\', value: 1 },\\n// { type: \'OP_ADD\' },\\n// { type: \'NUMBER\', value: 20 },\\n// { type: \'PAREN_CLOSE\' },\\n// { type: \'OP_MULTIPLY\' },\\n// { type: \'NUMBER\', value: -3.14 },\\n// { type: \'PAREN_CLOSE\' },\\n// { type: \'OP_DIVIDE\' },\\n// { type: \'NUMBER\', value: 9 }\\n// ]\\n
I hope this article gave you a little peek into the world of tokenization and how simple logic can transform a string into a structured array of tokens. This is the basis for more complex problems, and understanding this process is crucial for building your own interpreters and compilers in the future.
\\nIf you\'re feeling up to it, try your hand at math functions, such as sin()
and cos()
, variables, or even more complex operators. It\'s a fun exercise and you can come up with all sorts of edge cases that need handling!
Last updated: February 18, 2025 View on GitHub
","description":"In the previous article on Reverse Polish Notation, I skimmed over a pretty important detail, that of math expression tokenization. I thought I\'d tackle this problem on its own, as it may help you understand the fundamental logic of building more complex interpreters and…","guid":"https://www.30secondsofcode.org/js/s/math-expression-tokenizer","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-02-17T16:00:00.846Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/camper-school-bus-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/camper-school-bus-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Math "],"attachments":null,"extra":null,"language":null},{"title":"How can I parse Reverse Polish Notation in JavaScript?","url":"https://www.30secondsofcode.org/js/s/parse-reverse-polish-notation","content":"Reverse Polish Notation (RPN), also known as postfix notation, is a mathematical notation in which every operator follows all of its operands. This is in contrast to the more common infix notation, where operators are placed between operands.
\\nIf you\'re not familiar, it\'s better to show you a few examples:
\\nInfix Expression | \\nRPN Expression | \\n
---|---|
3 + 4 | \\n3 4 + | \\n
(3 + 4) * 5 | \\n3 4 + 5 * | \\n
3 + 4 - 5 * 6 / 8 | \\n3 4 + 5 6 * 8 / - | \\n
Parsing Reverse Polish Notation isn\'t a particularly difficult task. The key lies in the order of the operands and operators. Whenever you encounter an operator, you\'re guaranteed to have two operands before it, either directly before it or further back as a result of previous operations.
\\nBut how can we implement this in JavaScript? you may be asking. If you\'ve read my previous article, you may guess that we can use a stack to keep track of the operands and operators. And you\'d be right!
\\nIf you want to learn more about stacks, check out the previous article on the Stack data structure and its JavaScript implementation.
\\nAll we have to do is parse each token in the RPN expression and push it to the stack. If we encounter an operator, we pop the last two operands from the stack, perform the operation, and push the result back to the stack. We repeat this process until the entire expression has been parsed, and we\'re left with a single value in the stack, which is the result of evaluating the RPN expression.
\\n// Define the operands and their corresponding functions\\nconst operands = {\\n \'+\': (b, a) => a + b,\\n \'-\': (b, a) => a - b,\\n \'*\': (b, a) => a * b,\\n \'/\': (b, a) => a / b\\n};\\n\\nconst parseRPN = expression => {\\n // If the expression is empty, return 0\\n if (!expression.trim()) return 0;\\n\\n // Split the expression by whitespace\\n const tokens = expression.split(/\\\\s+/g);\\n\\n // Reduce the tokens array to a single value\\n return +tokens.reduce((stack, current) => {\\n // If the current token is an operator, pop the last two operands\\n // and perform the operation, then push the result back to the stack.\\n // Otherwise, push the current token to the stack.\\n if (current in operands)\\n stack.push(operands[current](+stack.pop(), +stack.pop()));\\n else stack.push(current);\\n\\n return stack;\\n }, []).pop();\\n};\\n\\nparseRPN(\'3 4 +\'); // 7\\nparseRPN(\'3 4 + 5 *\'); // 35\\nparseRPN(\'3 4 + 5 6 * 8 / -\'); // 3.25\\n
Tokenization isn\'t an issue here, as we can split the RPN expression by spaces to get an array of tokens. However, I\'m interested in writing a tokenization article soon, so stay tuned for that! We\'ll also not worry about error handling for now, but I\'m sure you can easily fit it in.
\\nThe implementation is pretty simple, using an array as a stack to keep track of the operands and operators. We then use Array.prototype.reduce()
to reduce the tokens to a single value, popping the last two operands whenever we encounter an operator.
The trickiest part, if you can call it that, is the order of operands when performing the operation. We need to be careful about the order of the operands when popping them from the stack. As the first operand to be popped from the stack should be the second operand in the operation, we need to reverse the order when performing the operation, thus resulting in the correct order. If that doesn\'t make sense, take a look at how the subtraction or division operations are implemented.
\\nPolish Notation (PN), also known as prefix notation, is very similar to Reverse Polish Notation, but the operators precede their operands. This means that the operator comes before the operands, which makes it easier to parse.
\\nInfix Expression | \\nRPN Expression | \\nPN Expression | \\n
---|---|---|
3 + 4 | \\n3 4 + | \\n+ 3 4 | \\n
(3 + 4) * 5 | \\n3 4 + 5 * | \\n* + 3 4 5 | \\n
3 + 4 - 5 * 6 / 8 | \\n3 4 + 5 6 * 8 / - | \\n- + 3 4 / * 5 6 8 | \\n
Having already implemented the Reverse Polish Notation parser, we can easily convert it to parse Polish Notation by using Array.prototype.reduceRight()
instead of Array.prototype.reduce()
and reversing the order of the operands when performing the operation, resulting in the opposite order than in RPN.
// Define the operands and their corresponding functions\\nconst operands = {\\n \'+\': (a, b) => a + b,\\n \'-\': (a, b) => a - b,\\n \'*\': (a, b) => a * b,\\n \'/\': (a, b) => a / b\\n};\\n\\nconst parsePN = expression => {\\n // If the expression is empty, return 0\\n if (!expression.trim()) return 0;\\n\\n // Split the expression by whitespace\\n const tokens = expression.split(/\\\\s+/g);\\n\\n // Reduce the tokens array to a single value\\n return +tokens.reduceRight((stack, current) => {\\n // If the current token is an operator, pop the last two operands\\n // and perform the operation, then push the result back to the stack.\\n // Otherwise, push the current token to the stack.\\n if (current in operands)\\n stack.push(operands[current](+stack.pop(), +stack.pop()));\\n else stack.push(current);\\n\\n return stack;\\n }, []).pop();\\n};\\n\\nparsePN(\'+ 3 4\'); // 7\\nparsePN(\'* + 3 4 5\'); // 35\\nparsePN(\'- + 3 4 / * 5 6 8\'); // 3.25\\n
Converting an infix expression to Reverse Polish Notation is a bit more complicated than parsing RPN. However, this problem has been long solved by Edsger Dijkstra in the form of the shunting yard algorithm.
\\nInstead of going over the details of the algorithm, which I\'m sure you can easily find online, I\'ll provide a simple JavaScript implementation of the algorithm to convert an infix expression to RPN.
\\nSome parts of the algorithm, such as functions and right-associative operators, are omitted for simplicity. This implementation is meant as a starting point, implementing just the four basic arithmetic operators, as well as single parentheses (e.g. ((3 + 4) * 5)
wont\'t work). Again, tokenization and error handling are not the focus here, but I may cover them in a future article.
// Define the operator precedence\\nconst precedence = { \'+\': 1, \'-\': 1, \'*\': 2, \'/\': 2 };\\n\\nconst toRPN = expression => {\\n // Tokenize the expression, splitting by whitespace first and processing\\n // individual tokens to handle parentheses and negative numbers.\\n const tokens = expression.split(/\\\\s+/g).reduce((tokens, token) => {\\n if (token.match(/^-?\\\\d+$/)) tokens.push(token);\\n else if (token in precedence) tokens.push(token);\\n else if (token.startsWith(\'(\')) tokens.push(\'(\', token.slice(1));\\n else if (token.endsWith(\')\')) tokens.push(token.slice(0, -1), \')\');\\n\\n return tokens;\\n }, []);\\n\\n // Reduce the tokens array to an output array and a stack array\\n const { output, stack } = tokens.reduce(\\n ({ output, stack }, token) => {\\n if (token in precedence) {\\n // If the token is an operator, pop operators from the stack with higher\\n // or equal precedence and push them to the output array. Then, push\\n // the current operator to the stack.\\n while (precedence[stack[stack.length - 1]] >= precedence[token])\\n output.push(stack.pop());\\n stack.push(token);\\n } else if (token === \'(\') {\\n // If the token is an opening parenthesis, push it to the stack.\\n stack.push(token);\\n } else if (token === \')\') {\\n // If the token is a closing parenthesis, pop operators from the stack\\n // until an opening parenthesis is encountered and push them to the\\n // output array.\\n while (stack[stack.length - 1] !== \'(\') output.push(stack.pop());\\n stack.pop();\\n } else {\\n // If the token is an operand, push it to the output array.\\n output.push(token);\\n }\\n\\n return { output, stack };\\n },\\n { output: [], stack: [] }\\n );\\n\\n // Return the output array concatenated with the reversed stack array.\\n return output.concat(stack.reverse()).join(\' \');\\n};\\n\\ntoRPN(\'3 + 4\'); // 3 4 +\\ntoRPN(\'(3 + 4) * 5\'); // 3 4 + 5 *\\ntoRPN(\'3 + 4 - (5 * 6) / 8\'); // 3 4 + 5 6 * 8 / -\\n
The implementation is a bit more complex than the RPN parser, but it\'s still quite simple. We tokenize the expression, then reduce the tokens array to an output and stack array, following the rules of the shunting yard algorithm. Finally, we return the output array concatenated with the reversed stack array, as the stack may still contain operators.
\\nIn this article, we took a brief, yet enlightening look at parsing Reverse Polish Notation in JavaScript. We implemented a simple RPN parser using a stack and the four basic arithmetic operators. We then converted the RPN parser to parse Polish Notation by reversing the order of the operands when performing the operation and, finally, implemented a simple infix to RPN converter using the shunting yard algorithm.
\\nI hope I didn\'t gloss over too many details and that you learned something along the way. If you have any questions, suggestions, or simply want to point out my mistakes, feel free to join the discussion on GitHub via the link below!
Last updated: February 15, 2025 View on GitHub ·\\nJoin the discussion
","description":"Reverse Polish Notation (RPN), also known as postfix notation, is a mathematical notation in which every operator follows all of its operands. This is in contrast to the more common infix notation, where operators are placed between operands.\\n\\nIf you\'re not familiar, it\'s better…","guid":"https://www.30secondsofcode.org/js/s/parse-reverse-polish-notation","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-02-14T16:00:00.525Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/flower-camera-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/flower-camera-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Math "],"attachments":null,"extra":null,"language":null},{"title":"How can I calculate the diff between two strings in JavaScript?","url":"https://www.30secondsofcode.org/js/s/myers-diff-algorithm","content":"Have you ever wondered how Git\'s diff algorithm works? From what I\'ve read, it\'s based on the Myers diff algorithm, which itself is based on solving the longest common subsequence problem, which I\'ve covered in a previous article. So, let\'s take a look at how we can implement this algorithm in JavaScript!
\\nI highly recommend you read the article on the longest common subsequence before diving into this one, as it will help you understand the underlying concepts better.
\\nI find that examples always help clarify things, so let\'s start with one. Assuming two sequences, a
and b
, we want to find the minimum number of edits required to convert a
into b
. These edits can be either insertions, deletions, or replacements. For this example, we\'ll start with two strings, but we\'ll later build a more general solution that can handle any sequence.
const a = \'kitten\';\\nconst b = \'sitting\';\\n
The expected output for this example should be something like this:
\\nconst diff = [\\n { value: \'k\', operation: \'delete\' },\\n { value: \'s\', operation: \'insert\' },\\n { value: \'i\', operation: \'equal\' },\\n { value: \'t\', operation: \'equal\' },\\n { value: \'t\', operation: \'equal\' },\\n { value: \'e\', operation: \'delete\' },\\n { value: \'i\', operation: \'insert\' },\\n { value: \'n\', operation: \'equal\' },\\n { value: \'g\', operation: \'insert\' }\\n];\\n
As you can see, we have a list of objects, each containing a value
and an operation
. The value
is the character from the original sequence, and the operation
is the type of edit required to convert the original sequence into the new one. If there is no edit required, the operation is equal
. We also make sure that delete
operations take precedence over insert
operations. This makes the output more readable and easier to understand.
Same as the previous problem, we\'ll use dynamic programming. The approach is very much similar to LCS, but with some tweaks. Instead of tiring you with all the details, I\'ll just explain the algorithm\'s steps, given two sequences, a
and b
, of lengths m
and n
:
(m + 1) x (n + 1)
matrix, dp
. Each column represents a character of a
, and each row represents a character of b
.0
to n
and the first column with the numbers 0
to m
, representing the number of deletions and insertions required to convert an empty string into the corresponding string.a[i - 1]
equals b[j - 1]
, set dp[i][j] = dp[i - 1][j - 1]
(no edit required).dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
(minimum of the three operations).i > 0
and j > 0
and a[i - 1]
equals b[j - 1]
, add an object with value: a[i - 1]
and operation: \'equal\'
to the diffs
array.i > 0
and either j === 0
or dp[i][j] === dp[i - 1][j] + 1
, add an object with value: a[i - 1]
and operation: \'delete\'
to the diffs
array (deletion takes precedence over insertion).value: b[j - 1]
and operation: \'insert\'
to the diffs
array.diffs
array when both i
and j
are 0
.There are various optimizations that can be made to this algorithm, but this article is meant primarily as a learning resource. Therefore, I\'ll keep things nice and simple and leave these optimizations out for now.
\\nAs you can probably tell, this is very similar to the LCS algorithm, but with a few tweaks to handle deletions and insertions. Let\'s implement this in JavaScript. We\'ll again make sure it can handle both strings and arrays as input.
\\nconst myersDiff = (a, b) => {\\n // Find the lengths of the two sequences\\n const m = a.length, n = b.length;\\n\\n // Create a 2D array to store the differences\\n const dp = Array(m + 1).fill(0).map(() => Array(n + 1).fill(0));\\n\\n // Initialize the first row and column\\n dp[0] = dp[0].map((_, j) => j);\\n dp.forEach((row, i) => row[0] = i);\\n\\n // Fill in the rest of the 2D array\\n for (let i = 1; i <= m; i++) {\\n for (let j = 1; j <= n; j++) {\\n if (a[i - 1] === b[j - 1]) {\\n dp[i][j] = dp[i - 1][j - 1];\\n } else {\\n dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);\\n }\\n }\\n }\\n\\n // Reconstruct the differences\\n let i = m, j = n, diffs = [];\\n\\n while (i > 0 || j > 0) {\\n if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {\\n diffs.unshift({ value: a[i - 1], operation: \'equal\' });\\n i--;\\n j--;\\n } else if (i > 0 && (j === 0 || dp[i][j] === dp[i - 1][j] + 1)) {\\n diffs.unshift({ value: a[i - 1], operation: \'delete\' });\\n i--;\\n } else {\\n diffs.unshift({ value: b[j - 1], operation: \'insert\' });\\n j--;\\n }\\n }\\n\\n return diffs;\\n}\\n\\nmyersDiff(\'kitten\', \'sitting\');\\n// [\\n// { value: \'k\', operation: \'delete\' },\\n// { value: \'s\', operation: \'insert\' },\\n// { value: \'i\', operation: \'equal\' },\\n// { value: \'t\', operation: \'equal\' },\\n// { value: \'t\', operation: \'equal\' },\\n// { value: \'e\', operation: \'delete\' },\\n// { value: \'i\', operation: \'insert\' },\\n// { value: \'n\', operation: \'equal\' },\\n// { value: \'g\', operation: \'insert\' }\\n// ]\\n
This is nice and all, but how would one go about visualizing it the way Git does? It\'s simple, actually! All we need to do is iterate over the diffs
array and print the values and a prefix based on the operation. While we\'re at it, we may as well add the number of additions and deletions required as an overview at the beginning of our output.
const visualizeDiff = (diffs) => {\\n const { insertions, deletions, output } = diffs.reduce(\\n (acc, { value, operation }) => {\\n if (operation === \'insert\') {\\n acc.insertions++;\\n acc.output.push(`+${value}`);\\n } else if (operation === \'delete\') {\\n acc.deletions++;\\n acc.output.push(`-${value}`);\\n } else {\\n acc.output.push(` ${value} `);\\n }\\n return acc;\\n }, { insertions: 0, deletions: 0, output: [] });\\n\\n const insertionsCount = insertions > 0 ? `${insertions} insertions(+), ` : \'\';\\n const deletionsCount = deletions > 0 ? `${deletions} deletions(-)` : \'\';\\n\\n return `${insertionsCount}${deletionsCount}\\\\n\\\\n${output.join(\'\\\\n\')}`;\\n};\\n
We\'ll explore a more complex example for this, using arrays of strings, instead of just strings, so that it looks a little more interesting.
\\nconst fileA = [\\n \'20 bottles of beer on the wall\',\\n \'20 bottles of beer\',\\n \'Take one down, pass it around\',\\n \'19 bottles of beer on the wall\'\\n];\\n\\nconst fileB = [\\n \'19 bottles of beer on the wall\',\\n \'19 bottles of beer\',\\n \'Take one down, pass it around\',\\n \'18 bottles of beer on the wall\'\\n];\\n\\nconst diffs = myersDiff(fileA, fileB);\\nvisualizeDiff(diffs);\\n// 3 insertions(+), 3 deletions(-)\\n//\\n// -20 bottles of beer on the wall\\n// +19 bottles of beer on the wall\\n// -20 bottles of beer\\n// +19 bottles of beer\\n// Take one down, pass it around\\n// -19 bottles of beer on the wall\\n// +18 bottles of beer on the wall\\n
Looks a lot like a Git diff, doesn\'t it? You can definitely make it even better by adding colors or skipping over long unchanged subsequences, but I\'ll leave that up to you.
\\nAnd there you have it! You can now find the differences between two sequences using the Myers diff algorithm and visualize them in a readable format. If you want to join the community discussion on this article, as well as the LCS one, use the link below to jump into the GitHub discussion!
Last updated: February 12, 2025 View on GitHub ·\\nJoin the discussion
","description":"Have you ever wondered how Git\'s diff algorithm works? From what I\'ve read, it\'s based on the Myers diff algorithm, which itself is based on solving the longest common subsequence problem, which I\'ve covered in a previous article. So, let\'s take a look at how we can implement…","guid":"https://www.30secondsofcode.org/js/s/myers-diff-algorithm","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-02-11T16:00:00.713Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/peaches-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/peaches-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," String "],"attachments":null,"extra":null,"language":null},{"title":"Find matching bracket pairs in a string with JavaScript","url":"https://www.30secondsofcode.org/js/s/find-matching-bracket-pairs","content":"I was recently honing my skills over at CodeWars and I came across an Esolang interpreter kata, that, ultimately, required me to find matching bracket pairs in a string. I\'ll spare you the details of the problem for now (maybe I\'ll write about it in the future), but I thought I\'d share the bracket matching solution with you, as I\'ve found it to be a recurring problem when building parsers or solving similar problems.
\\nI\'m sure that searching for this problem will yield lots of results, mostly variations of the same sort of solution, many of which contain no comments or explanations. I\'ll try to explain the solution as best as I can, starting from simple parentheses matching and then moving on to more complex bracket pairs.
\\nGiven a simple string with parentheses, like ((a + b))
, we want to find the matching pairs of parentheses. In this case, the pairs are (0, 8)
and (1, 7)
. The first pair is the outermost parentheses, and the second pair is the innermost parentheses.
Looking closely, we can identify the pattern we\'re looking for. If we come across an opening parenthesis, we have to store its index. When we find a closing parenthesis, we can pair it with the last opening parenthesis we found. This way, we can find the matching pairs in the given string.
\\nIf, however, we find a closing parenthesis without a matching opening parenthesis, we can safely assume that the string is invalid. Similarly, if we find an opening parenthesis without a matching closing parenthesis, the string is also invalid.
\\nTo solve this problem, we can use a stack-based approach. As we\'ll only need its push()
and pop()
methods, we can use a simple array to represent the stack. Let\'s see how we can implement this in JavaScript:
const findMatchingParentheses = str => {\\n const { stack, pairs } = [...str].reduce(\\n ({ pairs, stack }, char, i) => {\\n if (char === \'(\') stack.push(i);\\n else if (char === \')\') {\\n if (stack.length === 0) throw new Error(\'Invalid string\');\\n pairs.push([stack.pop(), i]);\\n }\\n return { pairs, stack };\\n },\\n { pairs: [], stack: [] }\\n );\\n\\n if (stack.length > 0) throw new Error(\'Invalid string\');\\n\\n return pairs;\\n};\\n\\nfindMatchingParentheses(\'a + b\'); // []\\nfindMatchingParentheses(\'(a + b)\'); // [[0, 6]]\\nfindMatchingParentheses(\'((a + b))\'); // [[1, 7], [0, 8]]\\nfindMatchingParentheses(\'((a + b)\'); // Error: Invalid string\\nfindMatchingParentheses(\'a + b)\'); // Error: Invalid string\\n
If you want to learn more about stacks, check out the article on the Stack data structure and how it can be implemented in JavaScript.
\\nNow that we\'ve seen how to find matching parentheses, we can extend the solution to matching bracket pairs. We\'ll consider the following bracket pairs: ()
, []
, and {}
.
The approach is similar to the one we used for parentheses. The main difference is that we\'ll use an object as a dictionary of opening and closing brackets. This way, we can easily check if a character is an opening or closing bracket.
\\nSame as before, we\'ll then push the index of the opening bracket to the stack. When we find a closing bracket, we\'ll pair it with the last opening bracket we found. If we find a closing bracket without a matching opening bracket, or vice versa, we\'ll throw an error.
\\nconst findMatchingBrackets = str => {\\n const bracketMap = {\\n \'(\': \')\',\\n \'[\': \']\',\\n \'{\': \'}\',\\n };\\n const bracketOpenings = Object.keys(bracketMap);\\n const bracketClosings = Object.values(bracketMap);\\n\\n const { stack, pairs } = [...str].reduce(\\n ({ pairs, stack }, char, i) => {\\n if (bracketOpenings.includes(char)) stack.push(i);\\n else if (bracketClosings.includes(char)) {\\n if (stack.length === 0) throw new Error(\'Invalid string\');\\n const openingIndex = stack.pop();\\n if (bracketMap[str[openingIndex]] !== char)\\n throw new Error(\'Invalid string\');\\n pairs.push([openingIndex, i]);\\n }\\n return { pairs, stack };\\n },\\n { pairs: [], stack: [] }\\n );\\n\\n if (stack.length > 0) throw new Error(\'Invalid string\');\\n\\n return pairs;\\n};\\n\\nfindMatchingBrackets(\'a + b\'); // []\\nfindMatchingBrackets(\'(a + b)\'); // [[0, 6]]\\nfindMatchingBrackets(\'((a + b))\'); // [[1, 7], [0, 8]]\\nfindMatchingBrackets(\'([a] + b)\'); // [[1, 3], [0, 8]]\\nfindMatchingBrackets(\'([{a} + b])\'); // [[2, 4], [1, 9], [0, 10]]\\nfindMatchingBrackets(\'((a + b)\'); // Error: Invalid string\\nfindMatchingBrackets(\'a + b)\'); // Error: Invalid string\\nfindMatchingBrackets(\'([a + b}\'); // Error: Invalid string\\n
Map
In many of the use cases I\'ve come across, a Map
is way easier to work with as a result, instead of an array of arrays. The simple fact of the matter is, in most scenarios, you\'ll want to retrieve the matching bracket index, given an opening or closing bracket index. This is where a Map
comes in handy.
Instead of storing pairs of indexes in an array, we can store them in a Map
. But, given that we may want to retrieve the matching index given an opening or closing bracket index, we\'ll need to store both sides of the pair in the Map
at the same time. Here\'s how using a Map
would look like:
const findMatchingBrackets = str => {\\n const bracketMap = {\\n \'(\': \')\',\\n \'[\': \']\',\\n \'{\': \'}\',\\n };\\n const bracketOpenings = Object.keys(bracketMap);\\n const bracketClosings = Object.values(bracketMap);\\n\\n const { stack, pairs } = [...str].reduce(\\n ({ pairs, stack }, char, i) => {\\n if (bracketOpenings.includes(char)) stack.push(i);\\n else if (bracketClosings.includes(char)) {\\n if (stack.length === 0) throw new Error(\'Invalid string\');\\n const openingIndex = stack.pop();\\n if (bracketMap[str[openingIndex]] !== char)\\n throw new Error(\'Invalid string\');\\n pairs.set(openingIndex, i);\\n pairs.set(i, openingIndex);\\n }\\n return { pairs, stack };\\n },\\n { pairs: new Map(), stack: [] }\\n );\\n\\n if (stack.length > 0) throw new Error(\'Invalid string\');\\n\\n return pairs;\\n};\\n\\nconst str = \'([{a} + b])\';\\nconst pairs = findMatchingBrackets(str);\\n\\n[...str].forEach((char, i) => {\\n if (pairs.has(i)) {\\n const matchingIndex = pairs.get(i);\\n const matchingChar = str[pairs.get(i)];\\n console.log(\\n `Bracket \\"${char}\\" at index ${i} matches with bracket \\"${matchingChar}\\" at index ${matchingIndex}`\\n );\\n }\\n});\\n// Bracket \\"(\\" at index 0 matches with bracket \\")\\" at index 10\\n// Bracket \\"[\\" at index 1 matches with bracket \\"]\\" at index 9\\n// Bracket \\"{\\" at index 2 matches with bracket \\"}\\" at index 4\\n// Bracket \\"}\\" at index 4 matches with bracket \\"{\\" at index 2\\n// Bracket \\"]\\" at index 9 matches with bracket \\"[\\" at index 1\\n// Bracket \\")\\" at index 10 matches with bracket \\"(\\" at index 0\\n
The solutions we\'ve seen so far handles parentheses and brackets. But, what if I told you it can handle far more complex inputs, such as HTML or XML tags? The solution can be generalized to handle any type of bracket pair, as long as you provide the rules for the opening and closing brackets and how they match.
\\nLet\'s expand our solution to accept the rules for the opening and closing brackets as arguments. This way, we can handle different types of bracket pairs, such as <
and >
for HTML tags. Here\'s how you can do it:
const findMatchingBrackets = bracketMap => {\\n const bracketOpenings = Object.keys(bracketMap);\\n const bracketClosings = Object.values(bracketMap);\\n\\n return str => {\\n const { stack, pairs } = [...str].reduce(\\n ({ pairs, stack }, char, i) => {\\n if (bracketOpenings.includes(char)) stack.push(i);\\n else if (bracketClosings.includes(char)) {\\n if (stack.length === 0) throw new Error(\'Invalid string\');\\n const openingIndex = stack.pop();\\n if (bracketMap[str[openingIndex]] !== char)\\n throw new Error(\'Invalid string\');\\n pairs.set(openingIndex, i);\\n pairs.set(i, openingIndex);\\n }\\n return { pairs, stack };\\n },\\n { pairs: new Map(), stack: [] }\\n );\\n\\n if (stack.length > 0) throw new Error(\'Invalid string\');\\n\\n return pairs;\\n };\\n};\\n\\nconst findBracketPairs =\\n findMatchingBrackets({ \'(\': \')\', \'[\': \']\', \'{\': \'}\' });\\nconst str = \'([{a} + b])\';\\nconst pairs = findBracketPairs(str);\\n// Map(6) {\\n// 0 => 10, 1 => 9, 2 => 4,\\n// 4 => 2, 9 => 1, 10 => 0\\n//}\\n\\nconst findHtmlPairs = findMatchingBrackets({ \'<\': \'>\' });\\nconst htmlStr = \'<div><p>Hello, world!</p></div>\';\\nconst htmlPairs = findHtmlPairs(htmlStr);\\n// Map(8) {\\n// 0 => 4, 5 => 7, 21 => 24, 25 => 30,\\n// 30 => 25, 24 => 21, 7 => 5, 4 => 0\\n// }\\n
I\'m using currying to create a function that accepts the bracket rules and returns a function that accepts the string to find the matching bracket pairs. This way, you can easily reuse the function with different inputs, given the same bracket rules.
\\nAs you can see, solving the matching bracket pairs problem is straightforward, using a stack-based approach. While this solution is fairly simple, it can easily be expanded upon for more complex tasks, such as incorporating a tokenizer to parse multi-character tokens (e.g. full HTML tags).
\\nI hope you found this article helpful and that you can use this solution in your projects or coding katas. See you in the next one! 👋
\\nLast updated: February 7, 2025 View On GitHub
","description":"I was recently honing my skills over at CodeWars and I came across an Esolang interpreter kata, that, ultimately, required me to find matching bracket pairs in a string. I\'ll spare you the details of the problem for now (maybe I\'ll write about it in the future), but I thought I…","guid":"https://www.30secondsofcode.org/js/s/find-matching-bracket-pairs","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-02-06T16:00:00.846Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/invention-shack-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/invention-shack-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," String "],"attachments":null,"extra":null,"language":null},{"title":"How can I find the longest common subsequence of two strings in JavaScript?","url":"https://www.30secondsofcode.org/js/s/longest-common-subsequence","content":"The longest common subsequence (LCS) is the longest subsequence common to all given sequences. It is not the same as the longest common substring, which must occupy consecutive positions within the original sequences. The LCS problem can be easily solved using dynamic programming.
\\nSolving this problem with dynamic programming is not the most efficient way to find the LCS. However, dynamic programming is the most straightforward way to understand the problem. If you\'re looking for a more efficient solution, you can search for it online.
\\nBefore we jump into the code, let\'s look at an example to understand the problem better. Consider the following two strings:
\\nconst string1 = \'AGGTAB\';\\nconst string2 = \'GXTXAYB\';\\n
The longest common subsequence of these two strings is \'GTAB\'
, which has a length of 4. But how did we arrive to this conclusion? What is trivial for human intuition may not be so for computer code. This is where dynamic programming comes into play.
Instead of trying to solve the problem directly, we can break it down into smaller subproblems. We can create a 2D table to store the lengths of the longest common subsequences of the prefixes of the two strings. Then, using the values in this table, we can build up the solution to the original problem.
\\nHere\'s a step-by-step guide to finding the LCS of these two strings using this approach. Use the buttons below the table to replay each step and move forward or backward.
\\n\\n\\n\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
G | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
G | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
T | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
G | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
T | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
T | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
T | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
T | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n2 | \\n2 | \\n2 | \\n
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
T | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n2 | \\n2 | \\n2 | \\n
A | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n3 | \\n3 | \\n3 | \\n
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
T | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n2 | \\n2 | \\n2 | \\n
A | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n3 | \\n3 | \\n3 | \\n
B | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n3 | \\n3 | \\n4 | \\n
Step 0
\\n\\nCool visualization, huh? But how does it work? I\'ll first present the short version of these steps, and then we\'ll dive into the details. Feel free to skip either one of the two, as long as you understand the concept.
\\nHere are the algorithm\'s steps, given two sequences, a
and b
, of lengths m
and n
:
(m + 1) x (n + 1)
matrix, dp
. Each column represents a character of a
, and each row represents a character of b
.0
), representing the number of matching characters between an empty string and the corresponding prefix.(i, j)
:\\na[i - 1]
equals b[j - 1]
, set dp[i][j] = dp[i - 1][j - 1] + 1
(increment the length of the LCS).dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
(take the maximum of the lengths of the LCS of the prefixes without the current characters).a[i - 1]
equals b[j - 1]
, add a[i - 1]
to the LCS and move diagonally upwards (to the cell (i - 1, j - 1)
).Like I said earlier, in order to solve the problem, we\'ll break it into smaller subproblems. The table in the visualization shows the lengths of the longest common subsequences of the prefixes of the two strings. The rows represent the characters of the first string, and the columns represent the characters of the second string. The cell at row i
and column j
contains the length of the longest common subsequence of the prefixes string1.slice(0, i)
and string2.slice(0, j)
. The empty string ε
represents the empty prefix (i.e., the length of the LCS of an empty string and any other string is 0
).
The table is filled in a bottom-up manner. We start with the empty string and build up the solution by considering the characters of the two strings one by one. If the characters match, we increment the length of the longest common subsequence by 1. If they don\'t match, we take the maximum of the lengths of the longest common subsequences of the prefixes without the current characters.
\\nHow do we get the LCS from this table? We start at the bottom right corner of the table and move diagonally upwards. If the characters at the current position match, we add the character to the LCS and move diagonally upwards. If they don\'t match, we move to the cell with the greater value. Doing so, we arrive at the LCS \'GTAB\'
.
Now that you better understand the problem and how to solve it, let\'s implement the algorithm in JavaScript. And, to spice things up a little, we\'ll make sure it can handle both strings and arrays.
\\nconst longestCommonSubsequence = (a, b) => {\\n // Find the lengths of the two sequences\\n const m = a.length, n = b.length;\\n\\n // Create a 2D array to store lengths\\n const dp = Array(m + 1).fill(0).map(() => Array(n + 1).fill(0));\\n\\n // Fill the 2D array\\n for (let i = 1; i <= m; i++) {\\n for (let j = 1; j <= n; j++) {\\n if (a[i - 1] === b[j - 1]) {\\n dp[i][j] = dp[i - 1][j - 1] + 1;\\n } else {\\n dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);\\n }\\n }\\n }\\n\\n // Reconstruct the LCS\\n let i = m, j = n, lcs = [];\\n\\n while (i > 0 && j > 0) {\\n if (a[i - 1] === b[j - 1]) {\\n lcs.unshift(a[i - 1]);\\n i--;\\n j--;\\n } else if (dp[i - 1][j] > dp[i][j - 1]) {\\n i--;\\n } else {\\n j--;\\n }\\n }\\n\\n if (typeof a === \'string\' && typeof b === \'string\')\\n return lcs.join(\'\');\\n\\n return lcs;\\n}\\n\\nlongestCommonSubsequence(\\n \'AGGTAB\',\\n \'GXTXAYB\'\\n); // \'GTAB\'\\n\\nlongestCommonSubsequence(\\n [\'A\',\'B\',\'C\',\'B\',\'A\'],\\n [\'C\', \'B\', \'A\', \'B\', \'A\', \'C\']\\n); // [\'C\', \'B\', \'A\']\\n
Our function takes two sequences, a
and b
, as arguments. It creates a 2D array dp
to store the lengths of the longest common subsequences of the prefixes of the two sequences. It then fills the array using the algorithm we discussed earlier. Finally, it reconstructs the LCS by moving diagonally upwards in the table. The function returns the LCS as a string if both a
and b
are strings, or as an array otherwise.
If this feels eerily similar to the Levenshtein distance algorithm, it\'s because it is! Both algorithms use dynamic programming to solve relatively similar problems.
\\nThat\'s all there is to it! You now know how to find the longest common subsequence of two strings or arrays using dynamic programming in JavaScript. If you have any questions or need further clarification, feel free to join the GitHub discussion, using the link below! I\'d love to hear your feedback on the visualization as well, as I\'m interested in creating more of these for future articles.
Last updated: February 3, 2025 View on GitHub ·\\nJoin the discussion
","description":"The longest common subsequence (LCS) is the longest subsequence common to all given sequences. It is not the same as the longest common substring, which must occupy consecutive positions within the original sequences. The LCS problem can be easily solved using dynamic…","guid":"https://www.30secondsofcode.org/js/s/longest-common-subsequence","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-02-02T16:00:00.487Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/carrots-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/carrots-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," String "],"attachments":null,"extra":null,"language":null},{"title":"Formatting day and hour ranges with JavaScript","url":"https://www.30secondsofcode.org/js/s/formatting-day-hour-ranges","content":"I\'ve long held the belief that algorithmic challenges are sort of a rarity in the world of web development, especially in frontend. But every now and then, you come across a problem that makes you think a little harder. This is exactly the case for a sort of formatting problem I recently encountered.
\\nInstead of boring you with all the details, I\'ll get straight to the specifics. Given an array of objects, containing day names and respective working hour ranges, I needed to format this data into an array of human-readable strings.
\\nLet\'s look at an example to make things clearer:
\\n// Sample input:\\nconst inputData = [\\n { day: \'Tuesday\', from: \'09:00\', to: \'17:00\' },\\n { day: \'Wednesday\', from: \'09:00\', to: \'17:00\' },\\n { day: \'Thursday\', from: \'09:00\', to: \'17:00\' },\\n { day: \'Friday\', from: \'09:00\', to: \'17:00\' },\\n { day: \'Saturday\', from: \'10:00\', to: \'14:00\' }\\n];\\n\\n// Expected output:\\nconst outputData = [\\n \'Monday: Closed\',\\n \'Tuesday - Friday: 09:00 - 17:00\',\\n \'Saturday: 10:00 - 14:00\',\\n \'Sunday: Closed\'\\n];\\n
Before even attempting to implement a solution, it\'s a good idea to break down the problem. In my head, this translates to the following subproblems:
\\nWhile my initial thoughts were to tackle these subproblems in order, I quickly realized that the third subproblem could be solved in parallel with the first one. This would make the solution more efficient and easier to implement.
\\nHaving broken down the problem, the solution can be approached in smaller steps, incrementally building up the final result. Let\'s start with the first subproblem.
\\nThis is the most algorithmic part of the problem and the one that may require the most thought to get right. I was vaguely aware of the fact that this problem is quite similar to the arrays of consecutive elements problem I\'ve previously solved.
\\nAfter a little bit of thought, I came up with a simple solution. I could use Array.prototype.reduce()
to iterate over array elements. But, as mentioned before, there may be missing elements. To account for this, instead of looping over the array, I decided to loop over the days of the week, a well-known set of elements.
Having the days of the week as a reference, I could then check if the current day matches the next day in the array. If it does, I could group them together. If not, I could add the current group to the result and start a new group.
\\nFinally, if the element is missing, I could add a value of \'Closed\'
to the group. This would allow me to easily identify missing days and add them to the final result, as well as group consecutive missing days together.
const weekdays = [\'Monday\', \'Tuesday\', \'Wednesday\', \'Thursday\', \'Friday\', \'Saturday\', \'Sunday\'];\\n\\nconst formatDayRanges = (data) =>\\n weekdays.reduce((acc, day) => {\\n const dayData = data.find((d) => d.day === day);\\n\\n const hours = dayData ? `${dayData.from} - ${dayData.to}` : \'Closed\';\\n\\n if (acc.length && acc[acc.length - 1].hours === hours) {\\n acc[acc.length - 1].days.push(day);\\n } else {\\n acc.push({ days: [day], hours });\\n }\\n\\n return acc;\\n }, []);\\n\\n// Given the sample input from the problem definition\\nconst result = formatDayRanges(inputData);\\n// [\\n// { days: [\'Monday\'], hours: \'Closed\' },\\n// {\\n// days: [\'Tuesday\', \'Wednesday\', \'Thursday\', \'Friday\'],\\n// hours: \'09:00 - 17:00\'\\n// },\\n// { days: [\'Saturday\'], hours: \'10:00 - 14:00\' },\\n// { days: [\'Sunday\'], hours: \'Closed\' }\\n// ]\\n
That\'s a great start! The next step is to format the grouped days into human-readable strings. Notice that we\'ve already formatted the hour ranges in the previous step to make comparison easier, which will come in handy here.
\\nIn the case of a single day, we\'ll simply output the day name and the working hours. For multiple days, we\'ll output the first and last day of the range, followed by the working hours. Additionally, I\'d like to make sure that special ranges of days are formatted correctly, such as when the range spans the entire week, all weekdays, or just the weekend.
\\nconst workingDays = [\'Monday\', \'Tuesday\', \'Wednesday\', \'Thursday\', \'Friday\'];\\nconst weekendDays = [\'Saturday\', \'Sunday\'];\\nconst allDays = [...workingDays, ...weekendDays];\\n\\nconst formatDayRanges = (data) =>\\n allDays.reduce((acc, day) => {\\n const dayData = data.find((d) => d.day === day);\\n\\n const hours = dayData ? `${dayData.from} - ${dayData.to}` : \'Closed\';\\n\\n if (acc.length && acc[acc.length - 1].hours === hours) {\\n acc[acc.length - 1].days.push(day);\\n } else {\\n acc.push({ days: [day], hours });\\n }\\n\\n return acc;\\n }, []).map(({ days, hours }) => {\\n if (days.length === 1) return `${days[0]}: ${hours}`;\\n if (days.length === 7) return `Everyday: ${hours}`;\\n if (workingDays.every((day) => days.includes(day)))\\n return \'Weekdays: \' + hours;\\n if (weekendDays.every((day) => days.includes(day)))\\n return \'Weekend: \' + hours;\\n return `${days[0]} - ${days[days.length - 1]}: ${hours}`;\\n });\\n\\n// Given the sample input from the problem definition\\nconst result = formatDayRanges(inputData);\\n// [\\n// \'Monday: Closed\',\\n// \'Tuesday - Friday: 09:00 - 17:00\',\\n// \'Saturday: 10:00 - 14:00\',\\n// \'Sunday: Closed\'\\n// ]\\n
Notice that we\'re using a second loop over the first grouped result in the form of Array.prototype.map()
. This can be avoided, but I found it to be more readable and easier to understand. Moreover, the performance impact is negligible.
Another point of interest is the use of Array.prototype.every()
to check if all days in a range are working days or weekend days. This is a simple and efficient way to check for special cases. However, were we to swap the places of days
and workingDays
(or weekendDays
), we\'d end up in a situation where we\'d have to check for the length of the days
array. This is because Array.prototype.every()
would return true
for an empty array or an array with fewer elements than the workingDays
or weekendDays
arrays.
This problem was a great exercise in breaking down a problem into smaller subproblems and incrementally building up the solution. It\'s a great example of how algorithmic thought can come in handy for everyday problems, even in frontend development. Hope you enjoyed my approach and learned something new!
Last updated: January 29, 2025 View on GitHub
","description":"I\'ve long held the belief that algorithmic challenges are sort of a rarity in the world of web development, especially in frontend. But every now and then, you come across a problem that makes you think a little harder. This is exactly the case for a sort of formatting problem I…","guid":"https://www.30secondsofcode.org/js/s/formatting-day-hour-ranges","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-01-28T16:00:00.022Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/tram-car-2-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/tram-car-2-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Date "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object advanced relationships","url":"https://www.30secondsofcode.org/js/s/complex-object-advanced-relationships","content":"This article is part of a series, picking up where Modeling complex JavaScript object autoloading and console left off. If you haven\'t read the previous installments yet, I strongly advise you to do so to get the full context. This series is more of a show & tell hoping to inspire you to build your own advanced JavaScript projects.
\\nWe\'ve already come a long way implementing an ActiveRecord-like JavaScript object system. While we\'ve already covered models, records and queries extensively, I want to revisit the relationships between objects. The current implementation is fairly basic and I believe we can do plenty more to make it more powerful.
\\nThis time, we\'ll simply add a new model under our models
directory. We\'ll also implement the relevant factory for it to match the rest of the implementation. And we\'ll make a few updates in the Model
class and our models to support the new features.
src/\\n├── config/\\n│ └── settings.js\\n├── core/\\n│ ├── model.js\\n│ ├── recordSet.js\\n│ ├── serializer.js\\n│ └── factory.js\\n├── models/\\n│ ├── author.js\\n│ ├── category.js\\n│ └── post.js\\n├── scripts/\\n│ ├── autoload.js\\n│ └── console.js\\n└── serializers/\\n ├── postSerializer.js\\n └── postPreviewSerializer.js\\nspec/\\n└── factories/\\n ├── authorFactory.js\\n ├── categoryFactory.js\\n └── postFactory.js\\n
You may need a code refresher before you begin and I\'ve got you covered. The entire implementation thus far is available in the code summary of the previous article.
\\nBefore we begin, I want to address a couple of things to make the rest of the implementation easier. Firstly, some cleaning up in the Model
class\'s prepare
method. Then, we\'ll add our new Category
model and factory, and finally, we\'ll add a relationships to our Post
model.
prepare
In the past, we\'ve heavily relied on the prepare
method of the Model
class to set up our models. As we\'re going to add even more logic to it, the arguments will start becoming a little hard to manage. Let\'s switch from a list of arguments to an object.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n // ...\\n }\\n\\n // ...\\n}\\n
This small change will break everything, so we need to update the models accordingly.
\\nimport Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, {\\n fields: [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true, inspectable: false }],\\n ],\\n validations: [record => record.email.includes(\'@\')],\\n });\\n }\\n\\n // ...\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, {\\n fields: [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n [\'authorId\', \'number\'],\\n ],\\n });\\n }\\n\\n // ...\\n}\\n
Category
modelWe\'ll now create a Category
model and a relevant CategoryFactory
to match the rest of the implementation. We\'ll use this model as the main example for some of our relationships later in the article.
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n get posts() {\\n return Post.where({ categoryId: this.id });\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Category from \'#src/models/category.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Category #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n};\\n\\nexport default class CategoryFactory extends Factory {\\n static {\\n super.prepare(this, Category, base);\\n }\\n}\\n
But wait! Posts don\'t have a categoryId
field!, I hear you say. Let\'s add it, then!
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\nimport Category from \'#src/models/category.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, {\\n fields: [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n [\'authorId\', \'number\'],\\n [\'categoryId\', \'number\'],\\n ],\\n });\\n }\\n\\n // ...\\n\\n get category() {\\n return Category.find(this.categoryId);\\n }\\n}\\n
Now all of our posts are related to categories, much like they would in a real-world blogging setup. The relationship is one-to-many, where a post can belong to only one category, but a category can have many posts, same as it is with authors.
\\nWe\'ve previously defined relationships via the use of a field that is then used by an attribute method to fetch the related records. This works alright, but scaling the project will become a bit of a hassle. We\'ll now introduce a new way to define relationships, which will make the implementation more scalable.
\\nIn order to create relationship definitions, we\'ll lean on the prepare
method once more. We will be adding a relationships
array as part of our options object. The first element of the array will be the type of relationship, whereas the second one will be the related model.
Again, drawing inspiration from ActiveRecord, we\'ll start with the belongsTo
relationship. This relationship is used when a record belongs to another record. In this case, the record that belongs to the other record is the one defining the foreign key.
In our case, this is the Post
model, which belongs to an Author
via the authorId
field. The same applies to the relationship from Post
to Category
via the categoryId
field.
Let\'s start by adding the logic for the belongsTo
relationship in the Model
class.
// ...\\n\\nconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);\\nconst decapitalize = str => str.charAt(0).toLowerCase() + str.slice(1);\\nconst toSingular = str => str.replace(/s$/, \'\');\\n\\nexport default class Model {\\n static models = {};\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n const name = model.name;\\n\\n if (Model.models[name])\\n throw new Error(`Model ${name} has already been prepared`);\\n else Model.models[name] = model;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.validations = validations || [];\\n model.indexes = [];\\n model.fields = {};\\n\\n let relationshipFields = [];\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, target] = relationship;\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n [\'id\', ...fields, ...relationshipFields].forEach(field => {\\n // ...\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
As you can see, we\'ve added a new models
static variable to the Model
class. This serves as a storage for all initialized models. We then ensure that relationships are stored as field definitions in relationshipFields
, which will then be used to create the individual fields automatically.
Finally, we arrive at the getter definition. If you remember from previous articles, we use Object.defineProperty
towards the end of the prepare
method to redefine getters, so that they can utilize the getterCache
for performance reasons. In order to retain this performance enhancement, we need to create our new getter as configurable
, so that it can be then redefined later down the line.
Additionally, you may notice the targetModel
declaration is inside the get
method. This is a crucial point to take note of, as it ensures that the target model is fetched only when the getter is accessed. This is important, as models can be initialized in any order and, if we move it outside of the getter, it may not be available at definition time.
Having done all this, we can now update our Post
model to include the belongsTo
relationships. We\'ll have to delete the authorId
, categoryId
, author
and category
fields and methods, as they are no longer needed. And, this way, we can drop the imports for Author
and Category
as well.
import Model from \'#src/core/model.js\';\\n// Delete the imports for Author and Category\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, {\\n fields: [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n // Delete the authorId and categoryId fields\\n ],\\n relationships: [\\n [\'belongsTo\', \'author\'],\\n [\'belongsTo\', \'category\'],\\n ],\\n });\\n }\\n\\n // ...\\n\\n // Delete the author and category methods\\n}\\n
The previous code looks a lot tidier now and we can easily add more relationships in the future without having to worry about the implementation details. While we do not have any hasOne
relationships in our current setup, we\'ll go ahead an implement the logic regardless.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n // ...\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, target] = relationship;\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasOne\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(target)];\\n return targetModel.where({ [foreignKey]: this.id }).first;\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
As you can see, very little has changed from the previous setup. Instead of using the find
method on the other model, we use where
with a query that utilizes the foreignKey
to find the related record. This is a very simple implementation, but it should be enough for most use cases.
As the hasOne
relationships expects the foreignKey
to be on the other model, we don\'t need to add any additional fields to the current model, unlike belongsTo
relationships.
But why not findBy
? I hear you asking. Well, there is no guarantee that the target field is unique, thus it may not have an index. If you have implemented indexes that are not tied to uniqueness constraints or have tweaked findBy
to handle non-indexed fields, then you can use it instead.
Similar to the hasOne
relationship, we can implement a hasMany
relationship. To be honest, this is almost the exact same piece of code, except we\'ll remove the first
method call at the end of the getter.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n // ...\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, target] = relationship;\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasOne\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(target)];\\n return targetModel.where({ [foreignKey]: this.id }).first;\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasMany\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel =\\n Model.models[capitalize(toSingular(target))];\\n return targetModel.where({ [foreignKey]: this.id });\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
Ok, apart from that little detail, you may have noticed that we also ensure the model name is singular. This is on purpose, so we can declare the relationship in a more human-readable way. For example, in the Author
model, we can declare a hasMany
relationship to Post
as posts
instead of post
.
I know that my toSingular
method is very simplistic and will only work for some English words. Implementing a more robust solution is outside the scope of this article, however, we\'ll take a look at aliases and foreign keys in the next section, which should solve the problem even better.
Let\'s go ahead and add a hasMany
relationship to the Author
model. We\'ll also delete the posts
method, as it is no longer needed, as well as the import for the Post
model. While we\'re at it, let\'s do the exact same for the Category
model.
import Model from \'#src/core/model.js\';\\n// Delete the import for Post\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, {\\n fields: [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true, inspectable: false }],\\n ],\\n validations: [record => record.email.includes(\'@\')],\\n relationships: [[\'hasMany\', \'posts\']],\\n });\\n }\\n\\n // ...\\n\\n // Delete the posts method\\n}\\n
import Model from \'#src/core/model.js\';\\n// Delete the import for Post\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n relationships: [[\'hasMany\', \'posts\']],\\n });\\n }\\n\\n // ...\\n\\n // Delete the posts method\\n}\\n
Same as last time, I haven\'t implemented many-to-many relationships to minimize complexity. They\'re left as a task for the reader, if you want to get your hands dirty and you feel like you truly need them for your use case.
\\nSo far so good, but customization is definitely going to be an issue in the long run. I want to create a taxonomy of categories, too. This will require categories that have a parent category, which is a category itself. This is a self-referential relationship and we\'ll need to add a little bit of logic to handle it. And we need to address naming conventions, too.
\\nUp until this point, foreign keys have been dictated by the target model\'s name. This is a good default, but it\'s not always going to work. We may want to go down the field definition route and allow our second array element to be an object. If it is, we can let it define a target
(the model name) and a foreignKey
(the field name). This will allow us to effectively decouple the two.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n // ...\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, options] = relationship;\\n if (typeof options === \'string\') options = { target: options };\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = decapitalize(options.target);\\n const foreignKey =\\n options.foreignKey || `${decapitalize(options.target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasOne\') {\\n const propertyName = decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.where({ [foreignKey]: this.id }).first;\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasMany\') {\\n const propertyName = decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel =\\n Model.models[capitalize(toSingular(options.target))];\\n return targetModel.where({ [foreignKey]: this.id });\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
Great! Let\'s apply this to create a parent category relationship in the Category
model.
import Model from \'#src/core/model.js\';\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n relationships: [\\n [\\n \'belongsTo\',\\n { target: \'category\', foreignKey: \'parentId\' },\\n ],\\n ],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static root(records) {\\n return records.where({ parentId: null }).first;\\n }\\n}\\n
const rootCategory = new Category({ title: \'Root\', parentId: null });\\nconst subCategory = new Category({ title: \'Sub\', parentId: rootCategory.id });\\n\\nsubCategory.category;\\n// [Category #0x00000000]: { id: 0, title: \'Root\', parentId: null }\\n
Not quite what we were looking for. While the foreign key has the correct name and the relationship is applied, the relationship attribute is still called category
. We can fix this!
If you were guessing we\'re going to implement aliases via the as
property, you\'re absolutely right! This will allow us to define a custom name for the relationship attribute. Let\'s go ahead and modify the prepare
method one last time.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n // ...\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, options] = relationship;\\n if (typeof options === \'string\')\\n options = { target: options, as: decapitalize(options) };\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey =\\n options.foreignKey || `${decapitalize(options.target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasOne\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.where({ [foreignKey]: this.id }).first;\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasMany\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel =\\n Model.models[capitalize(toSingular(options.target))];\\n return targetModel.where({ [foreignKey]: this.id });\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
Now, we can make update the Category
model to reflect the new as
property.
import Model from \'#src/core/model.js\';\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n relationships: [\\n [\\n \'belongsTo\',\\n { target: \'category\', foreignKey: \'parentId\', as: \'parent\' },\\n ],\\n ],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static root(records) {\\n return records.where({ parentId: null }).first;\\n }\\n}\\n
const rootCategory = new Category({ title: \'Root\', parentId: null });\\nconst subCategory = new Category({ title: \'Sub\', parentId: rootCategory.id });\\n\\nsubCategory.parent;\\n// [Category #0x00000000]: { id: 0, title: \'Root\', parentId: null }\\n
What we did with the parent
relationship is what one would call a self-referential relationship. This is a relationship where a record can be related to another record of the same model. This concept can come in handy in various scenarios.
Another way to use this, for example, is to create the inverse relationship, where a category can have many subcategories. There\'s nothing stopping us from defining it in the Category
model, same as before.
import Model from \'#src/core/model.js\';\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n relationships: [\\n [\\n \'belongsTo\',\\n { target: \'category\', foreignKey: \'parentId\', as: \'parent\' },\\n ],\\n [\\n \'hasMany\',\\n { target: \'category\', foreignKey: \'parentId\', as: \'children\' },\\n ],\\n ],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static root(records) {\\n return records.where({ parentId: null }).first;\\n }\\n}\\n
const rootCategory = new Category({ title: \'Root\', parentId: null });\\nconst subCategory = new Category({ title: \'Sub\', parentId: rootCategory.id });\\n\\nrootCategory.children;\\n// [Category #0x00000001]: { id: 1, title: \'Sub\', parentId: 0 }\\n
And there you have it! We\'ve successfully implemented self-referential relationships in our model, both in the form of a parent category and a category with subcategories.
\\nAfter a long and winding road, we\'ve finally implemented a very powerful relationship system for our models. We can now define relationships quickly and easily, customize them and even implement self-referential relationships. This is a very powerful setup and can help us scale our project to new heights.
\\nI hope you found this deep dive interesting and that you\'ve discovered a new way to think about complexity. This is going to be the last article of the series, at least for now. If you\'ve made it this far, thank you for sticking with me. Join the GitHub discussion via the link below, and let me know if you liked the series or if you have any questions.
\\nBefore I leave you, here\'s the entire implementation of this series. You can use this as a reference or as a starting point for your own projects. Enjoy!
\\nYou can also browse through the Code Reference on GitHub.
\\nconst settings = {\\n loader: {\\n modules: [\\n \'#src/core\',\\n \'#src/models\',\\n \'#src/serializers\',\\n \'#spec/factories\',\\n ],\\n },\\n};\\n
import RecordSet from \'#src/core/recordSet.js\';\\n\\nimport util from \'util\';\\n\\nutil.inspect.styles.record = \'blue\';\\nconst customInspectSymbol = Symbol.for(\'nodejs.util.inspect.custom\');\\n\\nconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);\\nconst decapitalize = str => str.charAt(0).toLowerCase() + str.slice(1);\\nconst toSingular = str => str.replace(/s$/, \'\');\\n\\nexport default class Model {\\n static models = {};\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n const name = model.name;\\n\\n if (Model.models[name])\\n throw new Error(`Model ${name} has already been prepared`);\\n else Model.models[name] = model;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.validations = validations || [];\\n model.indexes = [];\\n model.fields = {};\\n\\n let relationshipFields = [];\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, options] = relationship;\\n if (typeof options === \'string\')\\n options = { target: options, as: decapitalize(options) };\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey =\\n options.foreignKey || `${decapitalize(options.target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n // Note: targetModel MUST be evaluated inside the getter, as it could\\n // be defined after the current model\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasOne\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.where({ [foreignKey]: this.id }).first;\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasMany\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel =\\n Model.models[capitalize(toSingular(options.target))];\\n return targetModel.where({ [foreignKey]: this.id });\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n [\'id\', ...fields, ...relationshipFields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n inspectable: true,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n ...fieldOptions,\\n type: \'number\',\\n allowEmpty: false,\\n unique: true,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const {\\n type: dataType,\\n allowEmpty,\\n defaultValue,\\n unique,\\n inspectable,\\n } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n let fieldChecker = fieldTypeChecker;\\n if (unique) {\\n model.indexes.push(fieldName);\\n\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name][fieldName].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue, inspectable };\\n });\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = model.indexes.reduce((acc, index) => {\\n acc[index] = new Map();\\n return acc;\\n }, {});\\n }\\n\\n Object.entries(Object.getOwnPropertyDescriptors(model.prototype)).forEach(\\n ([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n }\\n );\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker, defaultValue }]) => {\\n this[fieldName] = data[fieldName] ?? defaultValue;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n this.constructor.validations?.forEach(validation => {\\n if (!validation(this, Model.instances[modelName])) {\\n throw new Error(\\n `Invalid data for ${modelName} model: ${JSON.stringify(this)}`\\n );\\n }\\n });\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n this.constructor.indexes.forEach(index => {\\n Model.indexedInstances[modelName][index].set(this[index], this);\\n });\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].id.get(id);\\n }\\n\\n static findBy(fieldAndValue) {\\n const entries = Object.entries(fieldAndValue);\\n if (entries.length !== 1)\\n throw new Error(\'findBy method must receive a single field/value pair\');\\n\\n const [fieldName, value] = entries[0];\\n return this.indexedInstances[this.name][fieldName].get(value);\\n }\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const id = `0x${this.id.toString(16).slice(0, 8).padStart(8, \'0\')}`;\\n const inspectable = Object.entries(this.constructor.fields).reduce(\\n (obj, [fieldName, { inspectable }]) => {\\n if (inspectable) obj[fieldName] = this[fieldName];\\n return obj;\\n },\\n {}\\n );\\n\\n if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, \'record\');\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...inspectable }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
export default class Serializer {\\n static prepare(serializer, serializableAttributes) {\\n serializer.serializableAttributes = [];\\n\\n serializableAttributes.forEach((attribute) => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n if (typeof alias === \\"string\\") return this.subject[alias];\\n if (typeof alias === \\"function\\")\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map((subject) => this.serialize(subject, options));\\n }\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nconst sequenceSymbol = Symbol(\'sequence\');\\n\\nconst isSequence = value =>\\n value && typeof value[sequenceSymbol] === \'function\';\\n\\nexport default class Factory {\\n static factoryMap = new Map();\\n static modelMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n const factoryBase = {};\\n\\n Object.keys(base).forEach(key => {\\n const value = base[key];\\n const getter = isSequence(value) ? value[sequenceSymbol] : () => value;\\n Object.defineProperty(factoryBase, key, {\\n get: getter,\\n enumerable: true,\\n });\\n });\\n\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...factoryBase };\\n\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n },\\n });\\n }\\n\\n static sequence = (fn = n => n) => {\\n let i = 0;\\n const sequence = () => fn(i++);\\n return { [sequenceSymbol]: sequence };\\n };\\n\\n static build(model, ...desiredTraits) {\\n return Factory.modelMap.get(model).build(...desiredTraits);\\n }\\n\\n static buildArray(model, count, ...desiredTraits) {\\n return Array.from({ length: count }, () =>\\n Factory.build(model, ...desiredTraits)\\n );\\n }\\n\\n static clear(model) {\\n Model.instances[model] = [];\\n Model.indexedInstances[model] = new Map();\\n Model.getterCache[model] = {};\\n }\\n\\n static clearAll() {\\n Model.instances = {};\\n Model.indexedInstances = {};\\n Model.getterCache = {};\\n }\\n}\\n
import { readdir } from \'node:fs/promises\';\\n\\nimport settings from \'#src/config/settings.js\';\\n\\nconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);\\n\\nconst autoload = async () => {\\n const moduleMap = new Map();\\n\\n for (const path of settings.loader.modules) {\\n // Read each directory (this requires a path relative to the project root)\\n const moduleFiles = await readdir(path.replace(/^#/, \'./\'));\\n\\n for (const moduleFile of moduleFiles) {\\n // Convert the file name to a module name (e.g., post.js -> Post)\\n const moduleName = capitalize(moduleFile.split(\'.\')[0]);\\n\\n if (!moduleMap.has(moduleName)) {\\n // Dynamically import the module and add it to the map\\n const module = await import(`${path}/${moduleFile}`);\\n moduleMap.set(moduleName, module.default);\\n } else throw new Error(`Duplicate class name: ${moduleName}`);\\n }\\n }\\n\\n // Convert the map to an object and return it, so that it can be exported\\n return Object.fromEntries(moduleMap.entries());\\n};\\n\\nconst modules = await autoload();\\n\\nexport default { ...modules, settings };\\n
import repl from \'node:repl\';\\nimport modules from \'#src/scripts/autoload.js\';\\n\\n// Start the REPL server\\nconst replServer = repl.start();\\n// Set up a history file for the REPL\\nreplServer.setupHistory(\'repl.log\', () => {});\\n\\n// Add the autoloaded modules to the REPL context\\nObject.entries(modules).forEach(([moduleName, module]) => {\\n replServer.context[moduleName] = module;\\n});\\n
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, {\\n fields: [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n ],\\n relationships: [\\n [\'belongsTo\', \'author\'],\\n [\'belongsTo\', \'category\'],\\n ],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n// Delete: import Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, {\\n fields: [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true, inspectable: false }],\\n ],\\n validations: [record => record.email.includes(\'@\')],\\n relationships: [[\'hasMany\', \'posts\']],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n relationships: [\\n [\'hasMany\', \'posts\'],\\n [\\n \'belongsTo\',\\n { target: \'category\', foreignKey: \'parentId\', as: \'parent\' },\\n ],\\n [\\n \'hasMany\',\\n { target: \'category\', foreignKey: \'parentId\', as: \'children\' },\\n ],\\n ],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static root(records) {\\n return records.where({ parentId: null }).first;\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst idSequence = Factory.sequence();\\n\\nconst base = {\\n id: idSequence,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n super.prepare(this, Author, base);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Post #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n super.prepare(this, Post, base, traits);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Category from \'#src/models/category.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Category #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n parentId: null,\\n};\\n\\nexport default class CategoryFactory extends Factory {\\n static {\\n super.prepare(this, Category, base);\\n }\\n}\\n
Last updated: January 23, 2025 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series, picking up where Modeling complex JavaScript object autoloading and console left off. If you haven\'t read the previous installments yet, I strongly advise you to do so to get the full context. This series is more of a show & tell…","guid":"https://www.30secondsofcode.org/js/s/complex-object-advanced-relationships","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-01-22T16:00:00.118Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/digital-nomad-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/digital-nomad-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object autoloading and console","url":"https://www.30secondsofcode.org/js/s/complex-object-autoloading-console","content":"This article is part of a series, following Modeling complex JavaScript object field validation. It\'s highly recommended to read the previous articles to get the full context. The whole series is more of a show & tell hoping to inspire you to start that advanced JavaScript project you\'ve been thinking about.
\\nSo far in this series, we\'ve developed models, queries, scopes, serialization, and factories for our ActiveRecord-inspired project. As the project grows larger, I want to address autoloading and a console environment to interact with our objects. In this installment, we\'ll create an inspect utility to help us work with objects in the console.
\\nThis time, we\'re expanding the directory structure just a little. We\'ll add a new config
directory for settings and a scripts
directory for our autoload and console scripts.
src/\\n├── config/\\n│ └── settings.js\\n├── core/\\n│ ├── model.js\\n│ ├── recordSet.js\\n│ ├── serializer.js\\n│ └── factory.js\\n├── models/\\n│ ├── author.js\\n│ └── post.js\\n├── scripts/\\n│ ├── autoload.js\\n│ └── console.js\\n└── serializers/\\n ├── postSerializer.js\\n └── postPreviewSerializer.js\\nspec/\\n└── factories/\\n ├── authorFactory.js\\n └── postFactory.js\\n
If you need a refresher of the entire implementation thus far, it\'s available in the code summary section at the end of the previous article.
\\nWhile developing the project up until this point, I\'ve used Node.js and its REPL to interact with the objects. However, I\'ve found it cumbersome to require all the modules manually. This concern will only get more significant as the project grows.
\\nBefore we dive into the autoloader, let\'s create a settings.js
file in the config
directory. This file will hold the settings for our project. For the time being, we\'ll simply define a setting for which modules to autoload.
const settings = {\\n loader: {\\n modules: [\\n \'#src/core\',\\n \'#src/models\',\\n \'#src/serializers\',\\n \'#spec/factories\',\\n ],\\n },\\n};\\n\\nexport default settings;\\n
While the settings file might be a bit of an overkill for what appears to be a single setting, it\'s a good practice to have a central place for all the settings. This way, you can easily add more settings as your project grows.
\\nOne of the features that I like a lot about ActiveRecord is the cleanliness of its modules. I never have to declare which modules I need, as they\'re all automatically loaded. Granted, this feels like magic and a bit of a black box, but it\'s a feature I want to replicate, at least to some extent.
\\nAfter fiddling around with a few ideas, such as generating an index file for each directory, I settled on a fairly simple solution.
\\nI\'ll first use the fs
module to read the directories and find all the files in them. Then, I\'ll create a Map
and use import()
to dynamically load each module. Provided that naming conventions are followed, I\'ll be able to deduce the module name from the file path, much like Rails does. Then, I\'ll convert the Map
to an object and export it.
import { readdir } from \'node:fs/promises\';\\n\\nimport settings from \'#src/config/settings.js\';\\n\\nconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);\\n\\nconst autoload = async () => {\\n const moduleMap = new Map();\\n\\n for (const path of settings.loader.modules) {\\n // Read each directory (this requires a path relative to the project root)\\n const moduleFiles = await readdir(path.replace(/^#/, \'./\'));\\n\\n for (const moduleFile of moduleFiles) {\\n // Convert the file name to a module name (e.g., post.js -> Post)\\n const moduleName = capitalize(moduleFile.split(\'.\')[0]);\\n\\n if (!moduleMap.has(moduleName)) {\\n // Dynamically import the module and add it to the map\\n const module = await import(`${path}/${moduleFile}`);\\n moduleMap.set(moduleName, module.default);\\n } else throw new Error(`Duplicate class name: ${moduleName}`);\\n }\\n }\\n\\n // Convert the map to an object and return it, so that it can be exported\\n return Object.fromEntries(moduleMap.entries());\\n};\\n\\nconst modules = await autoload();\\n\\nexport default { ...modules, settings };\\n
As you\'ll notice, this implementation is pretty barebones, as it only handles single-level directories and single-export modules. However, it\'s a good starting point for a small project. You can take a stab at improving it by adding more features, such as nested directories or multiple exports, if you need them.
\\nWith the autoloader in place, we can now focus on creating a console environment to interact with our objects. We\'ll start by setting up the console.js
script, then we\'ll create a custom object inspect utility.
The console.js
script will be the entry point for our console environment. It will import the autoloaded modules and set up the REPL. For that last part, we\'ll iterate over the modules and make them available in the REPL context.
import repl from \'node:repl\';\\nimport modules from \'#src/scripts/autoload.js\';\\n\\n// Start the REPL server\\nconst replServer = repl.start();\\n// Set up a history file for the REPL\\nreplServer.setupHistory(\'repl.log\', () => {});\\n\\n// Add the autoloaded modules to the REPL context\\nObject.entries(modules).forEach(([moduleName, module]) => {\\n replServer.context[moduleName] = module;\\n});\\n
We can now add a script to the package.json
file to start the console environment.
{\\n \\"scripts\\": {\\n \\"console\\": \\"node src/scripts/console.js\\"\\n }\\n}\\n
Let\'s run it with npm run console
and create an Author
record to see how things work.
Factory.build(\'Author\');\\n// Author {\\n// id: 0,\\n// name: \'Author\',\\n// surname: \'Authorson\',\\n// email: \'author@authornet.io\'\\n// }\\n
The previous output is alright, but when our models inevitably become huge, we might wish we had customized the object inspection. Luckily, Node.js provides a way to do just that, using the util.inspect.custom
symbol.
In order to make our records stand out, we\'ll start by adding a custom value to util.inspect.styles
, called record
and set it to blue
(as far as I can tell, nothing uses this style by default).
import RecordSet from \'#src/core/recordSet.js\';\\n\\nimport util from \'util\';\\n\\nutil.inspect.styles.record = \'blue\';\\nconst customInspectSymbol = Symbol.for(\'nodejs.util.inspect.custom\');\\n\\n// ...\\n
Adding custom styles like this is undocumented behavior, thus it may be subject to change in future versions of Node.js. However, the underlying structure seems to be a simple object, so I don\'t see any real risk to making this benign change at the time of writing. Please exercise caution, regardless.
\\nThen, we\'ll add the new method to our Model
class. For starters, let\'s just make sure that we wrap the model name with square brackets and add the record id. The rest is going to be the same, except we\'ll color the square bracket part, its contents and the opening and closing curly braces blue.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const { id } = this;\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...this }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
Now, when we run the console environment and create an Author
record, we\'ll see the following output.
Factory.build(\'Author\');\\n// [Author #0]: {\\n// id: 0,\\n// name: \'Author\',\\n// surname: \'Authorson\',\\n// email: \'author@authornet.io\'\\n// }\\n
The output is already looking better, but we can still improve it. For instance, if we have an array of Post
records, we might want to see the records compactly displayed.
You might have noticed the depth
parameter from the previous method. We can use it to control the depth of the inspection. We\'ll update our inspect utility to compact nested records, depending on the depth
parameter.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const { id } = this;\\n\\n if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, \'record\');\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...this }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
Factory.buildArray(\'Post\', 3);\\n// [ [Post #0], [Post #1], [Post #2] ]\\n
The depth
parameter is set to 2
by default. For the requirements of this example, 1
does the trick, but feel free to change the value in the condition to 0
, if you find it\'s more suitable for your needs.
One more thing we can do is convert the id to a hexadecimal string. This is more of a cosmetic change, but it can make the output look a little bit cleaner.
\\nAll this takes is the use of Number.prototype.toString()
with a 16
radix and String.prototype.padStart()
to ensure that the string is always 8
characters long.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const id = `0x${this.id.toString(16).slice(0, 8).padStart(8, \'0\')}`;\\n\\n if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, \'record\');\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...this }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
Factory.build(\'Author\');\\n// [Author #0x00000000]: {\\n// id: 0,\\n// name: \'Author\',\\n// surname: \'Authorson\',\\n// email: \'author@authornet.io\'\\n// }\\n
Why 8 characters long? Because, realistically, we\'re not going to have more than 2^32
records in memory at any given time. If you somehow end up with more than that, you can always increase the length. I\'d advise you to take a long, hard look at your design decisions, though, as this implementation isn\'t meant to handle such large datasets.
So far, all of the fields in our models have been inspectable, which is the default behavior. However, we might want to hide some fields from the inspection. Say, for instance, that we have a password
field or some personal data that is sensitive and shouldn\'t be displayed in the console.
Luckily, our field definition implementation from last time can be easily tweaked to support an inspectable
option. Let\'s update the prepare
method in the Model
class, as well as our inspect utility to respect this new option.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields, validations) {\\n // ...\\n\\n [\'id\', ...fields].forEach(field => {\\n // ...\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n inspectable: true,\\n };\\n\\n // ...\\n\\n const {\\n type: dataType,\\n allowEmpty,\\n defaultValue,\\n unique,\\n inspectable,\\n } = fieldOptions;\\n\\n // ...\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue, inspectable };\\n });\\n\\n // ...\\n }\\n\\n // ...\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const id = `0x${this.id.toString(16).slice(0, 8).padStart(8, \'0\')}`;\\n const inspectable = Object.entries(this.constructor.fields).reduce(\\n (obj, [fieldName, { inspectable }]) => {\\n if (inspectable) obj[fieldName] = this[fieldName];\\n return obj;\\n },\\n {}\\n );\\n\\n if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, \'record\');\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...inspectable }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
This simple change ensures that only fields marked as inspectable
(defaults to true
for all fields) are displayed in the console. We can then go ahead and mark the Author
model\'s email
field as not inspectable.
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(\\n this,\\n [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true, inspectable: false }],\\n ],\\n [record => record.email.includes(\'@\')]\\n );\\n }\\n\\n // ...\\n}\\n
Factory.build(\'Author\');\\n// [Author #0x00000000]: { id: 0, name: \'Author\', surname: \'Authorson\' }\\n
Our ActiveRecord-inspired project is starting to address the pains of scaling. We can finally easily load all the modules and interact with them in the console. We\'ve also created a custom object inspect utility to help us debug our complex objects and hide sensitive data from the console.
\\nI may have a couple of things to address before we wrap the series, but this article is already long enough. Don\'t forget to join the discussion on GitHub, using the link down below, or just drop a reaction to let me know if you\'re enjoying the series!
\\nThe customary code summary of the entire implementation up until this point can be found below. Make sure to bookmark it, if you need a quick reference in the future.
\\nYou can also browse through the Code Reference on GitHub.
\\nconst settings = {\\n loader: {\\n modules: [\\n \'#src/core\',\\n \'#src/models\',\\n \'#src/serializers\',\\n \'#spec/factories\',\\n ],\\n },\\n};\\n
import RecordSet from \'#src/core/recordSet.js\';\\n\\nimport util from \'util\';\\n\\nutil.inspect.styles.record = \'blue\';\\nconst customInspectSymbol = Symbol.for(\'nodejs.util.inspect.custom\');\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model, fields, validations) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.validations = validations || [];\\n model.indexes = [];\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n inspectable: true,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n ...fieldOptions,\\n type: \'number\',\\n allowEmpty: false,\\n unique: true,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const {\\n type: dataType,\\n allowEmpty,\\n defaultValue,\\n unique,\\n inspectable,\\n } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n let fieldChecker = fieldTypeChecker;\\n if (unique) {\\n model.indexes.push(fieldName);\\n\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name][fieldName].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue, inspectable };\\n });\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = model.indexes.reduce((acc, index) => {\\n acc[index] = new Map();\\n return acc;\\n }, {});\\n }\\n\\n Object.entries(Object.getOwnPropertyDescriptors(model.prototype)).forEach(\\n ([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n }\\n );\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker, defaultValue }]) => {\\n this[fieldName] = data[fieldName] ?? defaultValue;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n this.constructor.validations?.forEach(validation => {\\n if (!validation(this, Model.instances[modelName])) {\\n throw new Error(\\n `Invalid data for ${modelName} model: ${JSON.stringify(this)}`\\n );\\n }\\n });\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n this.constructor.indexes.forEach(index => {\\n Model.indexedInstances[modelName][index].set(this[index], this);\\n });\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].id.get(id);\\n }\\n\\n static findBy(fieldAndValue) {\\n const entries = Object.entries(fieldAndValue);\\n if (entries.length !== 1)\\n throw new Error(\'findBy method must receive a single field/value pair\');\\n\\n const [fieldName, value] = entries[0];\\n return this.indexedInstances[this.name][fieldName].get(value);\\n }\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const id = `0x${this.id.toString(16).slice(0, 8).padStart(8, \'0\')}`;\\n const inspectable = Object.entries(this.constructor.fields).reduce(\\n (obj, [fieldName, { inspectable }]) => {\\n if (inspectable) obj[fieldName] = this[fieldName];\\n return obj;\\n },\\n {}\\n );\\n\\n if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, \'record\');\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...inspectable }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
export default class Serializer {\\n static prepare(serializer, serializableAttributes) {\\n serializer.serializableAttributes = [];\\n\\n serializableAttributes.forEach((attribute) => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n if (typeof alias === \\"string\\") return this.subject[alias];\\n if (typeof alias === \\"function\\")\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map((subject) => this.serialize(subject, options));\\n }\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nconst sequenceSymbol = Symbol(\'sequence\');\\n\\nconst isSequence = value =>\\n value && typeof value[sequenceSymbol] === \'function\';\\n\\nexport default class Factory {\\n static factoryMap = new Map();\\n static modelMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n const factoryBase = {};\\n\\n Object.keys(base).forEach(key => {\\n const value = base[key];\\n const getter = isSequence(value) ? value[sequenceSymbol] : () => value;\\n Object.defineProperty(factoryBase, key, {\\n get: getter,\\n enumerable: true,\\n });\\n });\\n\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...factoryBase };\\n\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n },\\n });\\n }\\n\\n static sequence = (fn = n => n) => {\\n let i = 0;\\n const sequence = () => fn(i++);\\n return { [sequenceSymbol]: sequence };\\n };\\n\\n static build(model, ...desiredTraits) {\\n return Factory.modelMap.get(model).build(...desiredTraits);\\n }\\n\\n static buildArray(model, count, ...desiredTraits) {\\n return Array.from({ length: count }, () =>\\n Factory.build(model, ...desiredTraits)\\n );\\n }\\n\\n static clear(model) {\\n Model.instances[model] = [];\\n Model.indexedInstances[model] = new Map();\\n Model.getterCache[model] = {};\\n }\\n\\n static clearAll() {\\n Model.instances = {};\\n Model.indexedInstances = {};\\n Model.getterCache = {};\\n }\\n}\\n
import { readdir } from \'node:fs/promises\';\\n\\nimport settings from \'#src/config/settings.js\';\\n\\nconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);\\n\\nconst autoload = async () => {\\n const moduleMap = new Map();\\n\\n for (const path of settings.loader.modules) {\\n // Read each directory (this requires a path relative to the project root)\\n const moduleFiles = await readdir(path.replace(/^#/, \'./\'));\\n\\n for (const moduleFile of moduleFiles) {\\n // Convert the file name to a module name (e.g., post.js -> Post)\\n const moduleName = capitalize(moduleFile.split(\'.\')[0]);\\n\\n if (!moduleMap.has(moduleName)) {\\n // Dynamically import the module and add it to the map\\n const module = await import(`${path}/${moduleFile}`);\\n moduleMap.set(moduleName, module.default);\\n } else throw new Error(`Duplicate class name: ${moduleName}`);\\n }\\n }\\n\\n // Convert the map to an object and return it, so that it can be exported\\n return Object.fromEntries(moduleMap.entries());\\n};\\n\\nconst modules = await autoload();\\n\\nexport default { ...modules, settings };\\n
import repl from \'node:repl\';\\nimport modules from \'#src/scripts/autoload.js\';\\n\\n// Start the REPL server\\nconst replServer = repl.start();\\n// Set up a history file for the REPL\\nreplServer.setupHistory(\'repl.log\', () => {});\\n\\n// Add the autoloaded modules to the REPL context\\nObject.entries(modules).forEach(([moduleName, module]) => {\\n replServer.context[moduleName] = module;\\n});\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n [\'authorId\', \'number\'],\\n ]);\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(\\n this,\\n [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true, inspectable: false }],\\n ],\\n [record => record.email.includes(\'@\')]\\n );\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst idSequence = Factory.sequence();\\n\\nconst base = {\\n id: idSequence,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n super.prepare(this, Author, base);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Post #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n super.prepare(this, Post, base, traits);\\n }\\n}\\n
Last updated: January 16, 2025 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series, following Modeling complex JavaScript object field validation. It\'s highly recommended to read the previous articles to get the full context. The whole series is more of a show & tell hoping to inspire you to start that advanced…","guid":"https://www.30secondsofcode.org/js/s/complex-object-autoloading-console","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-01-15T16:00:00.923Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/digital-nomad-14-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/digital-nomad-14-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object field validation","url":"https://www.30secondsofcode.org/js/s/complex-object-field-validation","content":"This article is part of a series, picking up where Modeling complex JavaScript object factories left off. If you haven\'t read the previous installments yet, I recommend taking a look at them first. This series is more of a show & tell hoping to inspire you to build your own JavaScript projects.
\\nWe previously explored how create robust object models, records and collections, however, we\'ve not yet touched on how to validate the fields of these objects. This time around, we\'ll be focusing on adding constraints to individual fields, including type checking, empty and default values, and more.
\\nThis time around, we won\'t be making any changes to the existing directory structure. We\'ll only make some changes to our Model
class and update individual models
to include field validation.
src/\\n├── core/\\n│ ├── model.js\\n│ ├── recordSet.js\\n│ ├── serializer.js\\n│ └── factory.js\\n├── models/\\n│ ├── author.js\\n│ └── post.js\\n└── serializers/\\n ├── postSerializer.js\\n └── postPreviewSerializer.js\\nspec/\\n└── factories/\\n ├── authorFactory.js\\n └── postFactory.js\\n
You can find an implementation refresher in the code summary of the previous article, if you need to catch up.
\\nI find the semantics of relational databases and ActiveRecord to be quite nice for dictating object structure, so I\'ll loosely base my implementation on them. Thus, fields can have types, decide whether they can be empty or not, and define a default value.
\\nNotice the loosely part in my previous statement. The following setup is not 100% compatible with any relational database. It hinges on similar ideas and may transfer rather well in certain scenarios, however, it leaves a lot of wiggle room to the user, which may be easily abused, causing incompatible behavior.
\\nRelational databases have a small subset of data types that you can use. Instead of going down their specific implementation route, we\'ll adapt this concept to JavaScript. What we want is to be able to define a fields as a string
, number
, boolean
, or date
. We\'ll also throw in an any
type for good measure, as there are scenarios where more complex data may need to be stored.
We\'ll focus solely on scalar types, skipping vector types entirely. The latter would require a few implementation tweaks, that are left up to the reader to explore. Notice, however, that if you find yourself defining field constraints to work with JavaScript objects, you\'re most likely doing something wrong and need to go design a model for that object, instead.
\\nTo make this work, we\'ll have to add our field definitions as an argument to the prepare
method of our Model
class. This will allow us to define the fields and their constraints when creating a new model. To make the code a little more readable, we\'ll follow the example Serializer
, where each definition is either a string or an array.
What exactly is this field definition going to be? you may be asking. Simply put, single strings will be field names that will not be type-checked (any
type), whereas arrays will contain the field name as the first element and the data type as the second. We\'ll later see how the second value can be extended to include more constraints.
Let\'s start with the prepare
method in the Model
and make the necessary changes.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n const dataType = isAlias\\n ? field[1]\\n : fieldName === \'id\'\\n ? \'number\'\\n : \'any\';\\n\\n let fieldChecker;\\n if (dataType === \'any\') fieldChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n fieldChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n fieldChecker = value => value instanceof Date;\\n else\\n throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n model.fields[fieldName] = { fieldChecker };\\n });\\n\\n Object.entries(\\n Object.getOwnPropertyDescriptors(model.prototype),\\n ).forEach(([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n });\\n }\\n\\n // ...\\n}\\n
Some design decisions in this code snippet will come to make sense in the next couple of sections, as we add more constraints to our fields. If they feel overengineered at this point, it\'s because I stripped down the final implementation to its individual steps.
\\nThis seems like a lot, but we\'ve just added a loop to store the field definitions in the model. We\'ve also added a fieldChecker
to each field, which will be used to validate the field when creating a new record.
Yes, but these definitions don\'t do anything yet. Right. Let\'s go ahead and update the Model
class one more time. This time around, we\'ll make sure to use our new fields
definition in the constructor
. We\'l loop over this definition, find the fields we want to add to the record and validate them.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker }]) => {\\n this[fieldName] = data[fieldName] ?? null;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n // ...\\n}\\n
If you\'re not familiar with the nullish coalescing (??
) operator, I highly suggest reading up on the previous article on the topic. In this case, it\'s used instead of the logical OR (||
) operator to default to null
if the field is missing. This accounts for falsy values, such as 0
, which would be overridden by the logical OR operator.
Notice how we use this.constructor
to access the fields
definition. Again, we\'re using the fact that this
resolves to the calling subclass, which is the model we\'re creating an instance of. We\'re also using the data
object to populate the fields of the record. If a field is missing, we\'ll default to null
. This will practically break the type-checking if any value is empty, which we\'ll deal with in a minute.
Let\'s update our Post
and Author
models to include some field definitions. We\'ll also need to remove almost all logic from our constructor
s, as it\'s now handled in the Model
class.
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, [\\n [\'name\', \'string\'],\\n [\'surname\', \'string\'],\\n [\'email\', \'string\'],\\n ]);\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n // ...\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, [\\n [\'title\', \'string\'],\\n [\'content\', \'string\'],\\n [\'publishedAt\', \'date\'],\\n [\'authorId\', \'number\'],\\n ]);\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n // ...\\n}\\n
Having set up type-checking, we need to address the elephant in the room: null
. We need to allow some fields to be empty, but we want to control this on the field definition level. This begs the question: how?
First off, we need to decide the default setup for any field. We\'ve already decided to default to an any
type, but we need to decide if we should or shouldn\'t allow empty values. The path of least friction dictates that we should allow fields to be empty by default, while allowing the constraint to be explicitly defined.
To define said constraint, we\'ll make sure our field definition can handle objects as the second argument, thus allowing us to define more than just the data type. This object will consist of type
and allowEmpty
keys.
Let\'s update our prepare
method to handle this.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = { type: \'any\', allowEmpty: true };\\n if (fieldName === \'id\')\\n fieldOptions = { type: \'number\', allowEmpty: false };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else fieldOptions.type = field[1];\\n }\\n\\n const { type: dataType, allowEmpty } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n model.fields[fieldName] = { fieldChecker };\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
That\'s literally all we need to do. On top of type checking, we now have a check for the allowEmpty
constraint, slightly altering the fieldChecker
function. This will allow us to define fields that can be empty, while still enforcing the type constraint.
Let\'s make a couple of updates to our model field definitions.
\\nimport Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, [\\n [\'name\', { type: \'string\', allowEmpty: false },\\n [\'surname\', \'string\'],\\n [\'email\', \'string\'],\\n ]);\\n }\\n\\n // ...\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', \'date\'],\\n [\'authorId\', \'number\'],\\n ]);\\n }\\n\\n // ...\\n}\\n
Thus far, we\'ve worked under the assumption of null
being the empty value. However, we may want to default to a different value. This is especially useful for fields that are not allowed to be empty, but we still want to have a default value.
Same as before, we\'ll extend the definition to include a defaultValue
key, allowing us to define a default value for each field, which will be used if the field is empty. This requires a small change in the prepare
method and an update in the constructor
.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model, fields) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n type: \'number\',\\n allowEmpty: false,\\n defaultValue: null,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const { type: dataType, allowEmpty, defaultValue } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue };\\n });\\n\\n // ...\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker }]) => {\\n this[fieldName] = data[fieldName] ?? defaultValue;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n // ...\\n}\\n
This time around, we\'ll update our Post
with a default value for the publishedAt
field.
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n [\'authorId\', \'number\'],\\n ]);\\n }\\n\\n // ...\\n}\\n
const post = new Post({ id: 1, title: \'My post\' });\\n// {\\n// id: 1,\\n// title: \'My post\',\\n// content: \'null,\\n// publishedAt: 2025-01-09T00:00:00.000Z,\\n// authorId: null\\n// }\\n
A more complex constraint that is often required is field uniqueness. This is especially useful for fields like id
, which should be unique across all records of a model. This also opens up the potential for multiple indices on a model, which can be useful for searching records by different fields.
id
We\'ll start by constraining the id
field to ensure that it\'s unique across all records of a model. This needs yet another small change in the prepare
method of our Model
.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n type: \'number\',\\n allowEmpty: false,\\n defaultValue: null,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const { type: dataType, allowEmpty, defaultValue } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n let fieldChecker = fieldTypeChecker;\\n if (fieldName === \'id\') {\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue };\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
Such a small change, yet it allows us to constrain the id
field to be unique across all records of a model. Our indexedInstances
storage is leveraged to check for uniqueness, taking advantage of the performance of the Map
data structure. This will prevent us from creating multiple records with the same id
with a minimal performance overhead at record creation.
Our current implementation only allows for a single index, which is the id
field. We previously used this field to store the records in the indexedInstances
map. We\'ll need to update this structure to allow for multiple indices.
While we\'re at it, let\'s add a unique
constraint to the field definition, which will allow us to define fields that should be unique across all records of a model. Naturally, id
will be unique by default, but we can now define other fields as unique as well.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.indexes = [];\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n type: \'number\',\\n allowEmpty: false,\\n defaultValue: null,\\n unique: true,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const { type: dataType, allowEmpty, defaultValue, unique } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n let fieldChecker = fieldTypeChecker;\\n if (unique) {\\n model.indexes.push(fieldName);\\n\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name][fieldName].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue };\\n });\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = model.indexes.reduce((acc, index) => {\\n acc[index] = new Map();\\n return acc;\\n }, {});\\n }\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
We\'ve only covered single field indices in this implementation. Compound indices, while possible, require significant work to implement. You can give this a go, if you feel up to the challenge, but it felt like diminishing returns for this already long article.
\\nThis change breaks our Model
class, as find
and the constructor
need to account for the change in the underlying data structures. Let\'s make the necessary changes.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker }]) => {\\n this[fieldName] = data[fieldName] ?? null;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n this.constructor.indexes.forEach(index => {\\n Model.indexedInstances[modelName][index].set(this[index], this);\\n });\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].id.get(id);\\n }\\n\\n // ...\\n}\\n
That\'s it! Same performance and logic, more flexibility and we can add uniqueness constraints. Let\'s update our Author
model to make sure the email
field is unique.
import Model from \'#src/core/model.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true }],\\n ]);\\n }\\n\\n // ...\\n}\\n
The astute reader will notice that this specific setup will only allow for a single author to have an empty (null
) email. This is a side effect of combining the allowEmpty
and unique
constraints. In most practical use cases, this is a non-issue, as we\'d only want to enforce uniqueness on non-empty fields.
Our find
method is perfect when querying records by their id
. But, having multiple indices, we may as well add a findBy
method to leverage these data structures. This will allow us to query records by any field that has a unique constraint.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static findBy(fieldAndValue) {\\n const entries = Object.entries(fieldAndValue);\\n if (entries.length !== 1)\\n throw new Error(\'findBy method must receive a single field/value pair\');\\n\\n const [fieldName, value] = entries[0];\\n return this.indexedInstances[this.name][fieldName].get(value);\\n }\\n\\n // ...\\n}\\n
And let\'s see it in action for our Author
model, querying using the email
field.
const author = new Author({ id: 1, name: \'John\', email: \'john@authornet.io\' });\\n\\nAuthor.findBy({ email: \'john@authornet.io\' });\\n// Author { id: 1, name: \'John\', email: \'john@authornet.io\' }\\n
We\'ve covered the basics of individual field validation, but what if we want to apply custom validation conditions? This is hard to cover by the current setup, however, it\'s possible to implement a model-wide custom validation system.
\\nAfter tinkering with different approaches, I settled on a third argument to the prepare
method, which allows an optional array of validator functions to be passed. These, in turn, will be executed on the newly created record, in the constructor
, to ensure it meets the custom validation criteria.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields, validations) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.validations = validations || [];\\n model.indexes = [];\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n type: \'number\',\\n allowEmpty: false,\\n defaultValue: null,\\n unique: true,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const { type: dataType, allowEmpty, defaultValue, unique } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n let fieldChecker = fieldTypeChecker;\\n if (unique) {\\n model.indexes.push(fieldName);\\n\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name][fieldName].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue };\\n });\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = model.indexes.reduce((acc, index) => {\\n acc[index] = new Map();\\n return acc;\\n }, {});\\n }\\n\\n // ...\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker, defaultValue }]) => {\\n this[fieldName] = data[fieldName] ?? defaultValue;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n this.constructor.validations?.forEach(validation => {\\n if (!validation(this, Model.instances[modelName])) {\\n throw new Error(\\n `Invalid data for ${modelName} model: ${JSON.stringify(this)}`\\n );\\n }\\n });\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n this.constructor.indexes.forEach(index => {\\n Model.indexedInstances[modelName][index].set(this[index], this);\\n });\\n }\\n\\n // ...\\n}\\n
We can then add a custom validation to our Author
model, ensuring that the email
field contains an @
symbol as proper email validation is hard.
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(\\n this,\\n [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true }],\\n ],\\n [record => record.email.includes(\'@\')]\\n );\\n }\\n\\n // ...\\n}\\n
That\'s a wrap! Our models can finally be used to store structured data. We\'ve covered type constraints, emptiness constraints, default values, field uniqueness, and custom validators. This is a great starting point for a more complex system, which can be extended in many ways, or used to interface with a relational database.
\\nAs the project grows towards its final form, I want to address a couple more topics before the series is over. Stay tuned for the next installment and, if you feel like it, drop a reaction or a comment in the GitHub discussion, linked below. Until next time!
\\nThe complete implementation is summarized below, as is traditional by now. This includes all the changes we\'ve made to the Model
class, as well as the changes to the Author
and Post
models, and all previous implementations.
You can also browse through the Code Reference on GitHub.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model, fields, validations) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.validations = validations || [];\\n model.indexes = [];\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n type: \'number\',\\n allowEmpty: false,\\n defaultValue: null,\\n unique: true,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const { type: dataType, allowEmpty, defaultValue, unique } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n let fieldChecker = fieldTypeChecker;\\n if (unique) {\\n model.indexes.push(fieldName);\\n\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name][fieldName].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue };\\n });\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = model.indexes.reduce((acc, index) => {\\n acc[index] = new Map();\\n return acc;\\n }, {});\\n }\\n\\n Object.entries(Object.getOwnPropertyDescriptors(model.prototype)).forEach(\\n ([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n }\\n );\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker, defaultValue }]) => {\\n this[fieldName] = data[fieldName] ?? defaultValue;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n this.constructor.validations?.forEach(validation => {\\n if (!validation(this, Model.instances[modelName])) {\\n throw new Error(\\n `Invalid data for ${modelName} model: ${JSON.stringify(this)}`\\n );\\n }\\n });\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n this.constructor.indexes.forEach(index => {\\n Model.indexedInstances[modelName][index].set(this[index], this);\\n });\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].id.get(id);\\n }\\n\\n static findBy(fieldAndValue) {\\n const entries = Object.entries(fieldAndValue);\\n if (entries.length !== 1)\\n throw new Error(\'findBy method must receive a single field/value pair\');\\n\\n const [fieldName, value] = entries[0];\\n return this.indexedInstances[this.name][fieldName].get(value);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
export default class Serializer {\\n static prepare(serializer, serializableAttributes) {\\n serializer.serializableAttributes = [];\\n\\n serializableAttributes.forEach((attribute) => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n if (typeof alias === \\"string\\") return this.subject[alias];\\n if (typeof alias === \\"function\\")\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map((subject) => this.serialize(subject, options));\\n }\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nconst sequenceSymbol = Symbol(\'sequence\');\\n\\nconst isSequence = value =>\\n value && typeof value[sequenceSymbol] === \'function\';\\n\\nexport default class Factory {\\n static factoryMap = new Map();\\n static modelMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n const factoryBase = {};\\n\\n Object.keys(base).forEach(key => {\\n const value = base[key];\\n const getter = isSequence(value) ? value[sequenceSymbol] : () => value;\\n Object.defineProperty(factoryBase, key, {\\n get: getter,\\n enumerable: true,\\n });\\n });\\n\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...factoryBase };\\n\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n },\\n });\\n }\\n\\n static sequence = (fn = n => n) => {\\n let i = 0;\\n const sequence = () => fn(i++);\\n return { [sequenceSymbol]: sequence };\\n };\\n\\n static build(model, ...desiredTraits) {\\n return Factory.modelMap.get(model).build(...desiredTraits);\\n }\\n\\n static buildArray(model, count, ...desiredTraits) {\\n return Array.from({ length: count }, () =>\\n Factory.build(model, ...desiredTraits)\\n );\\n }\\n\\n static clear(model) {\\n Model.instances[model] = [];\\n Model.indexedInstances[model] = new Map();\\n Model.getterCache[model] = {};\\n }\\n\\n static clearAll() {\\n Model.instances = {};\\n Model.indexedInstances = {};\\n Model.getterCache = {};\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n [\'authorId\', \'number\'],\\n ]);\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(\\n this,\\n [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true }],\\n ],\\n [record => record.email.includes(\'@\')]\\n );\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst idSequence = Factory.sequence();\\n\\nconst base = {\\n id: idSequence,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n super.prepare(this, Author, base);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Post #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n super.prepare(this, Post, base, traits);\\n }\\n}\\n
Last updated: January 9, 2025 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series, picking up where Modeling complex JavaScript object factories left off. If you haven\'t read the previous installments yet, I recommend taking a look at them first. This series is more of a show & tell hoping to inspire you to…","guid":"https://www.30secondsofcode.org/js/s/complex-object-field-validation","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-01-08T16:00:00.482Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/planning-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/planning-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object factories","url":"https://www.30secondsofcode.org/js/s/complex-object-factories","content":"This article is part of a series, following Modeling complex JavaScript object serialization. It may still make sense on its own, but it\'s highly recommended to read the previous articles first. This series is more of a show & tell with the aim to inspire you to build more advanced JavaScript projects.
\\nIn the past four installments, we\'ve created models, queries, scopes and serialization for our ActiveRecord-inspired project. As the project grows larger, we might find a need to test our code. However, mocking things is hard, especially the more complex our objects get. This is where factories come in.
\\nOn top of what we previously built, we\'ll add a new class in the core
directory, called Factory
. We\'ll also start populating the spec
directory with factories
for our models.
src/\\n├── core/\\n│ ├── model.js\\n│ ├── recordSet.js\\n│ ├── serializer.js\\n│ └── factory.js\\n├── models/\\n│ ├── author.js\\n│ └── post.js\\n└── serializers/\\n ├── postSerializer.js\\n └── postPreviewSerializer.js\\nspec/\\n└── factories/\\n ├── authorFactory.js\\n └── postFactory.js\\n
As usual, you can find a refresher of the entire implementation so far in the code summary from the previous article.
\\nIn the world of testing, we often need to create mock data to test our code. This is often done with fixtures, static pieces of data that we can use to test our code. However, fixtures can be cumbersome to maintain, especially as our objects grow complex. They also often break or get outdated, making seemingly unrelated tests fail.
\\nThis is where factories come in. Factories are dynamic pieces of code that can generate mock data for our tests. They can be as simple or as complex as we need them to be, and they can be updated easily when our objects change. As far as I can tell, they are based on the Factory pattern from the Gang of Four book.
\\nFor this particular implementation, I\'m going to loosely base my factories on the Factory Bot gem for Ruby. The reason is that I\'m rather familiar with this library and I like using it, so that\'s where I\'m drawing my inspiration from.
\\nTo create our factories, we\'ll start by creating a Factory
class in the core
directory. As we usually do, we\'ll start simple and build from there.
First off, we will follow the by-now familiar pattern of using static initialization blocks to create a registry of factories. This will allow us to easily access our factories from anywhere in our code.
\\nBut what will the registry hold? I hear you asking. If we look to our models, each model needs some initial data
to pass to its constructor
. That\'s what we\'ll call the base of the factory. Then, to allow for more complex objects, we\'ll also add traits to our factories. Traits are essentially modifiers that can be applied to the base data to create customized objects.
Finishing up the setup, we\'ll also need to pass the model to the factory, so it knows what kind of object it\'s supposed to create. Putting everything together, we arrive at our first draft of the prepare
static method.
export default class Factory {\\n static factoryMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const factoryName = factory.name;\\n\\n // Store the factory in the factory map\\n if (!Factory.factoryMap[factoryName])\\n Factory.factoryMap.set(factoryName, {\\n model,\\n base,\\n traits,\\n });\\n }\\n}\\n
In some cases, you may want customizable traits with parameters. For example, you might want a few interdependent fields combined in a trait, but you may want to pass some parameter to create them. While this isn\'t exactly covered in this article, the next few sections cover a similar pattern, via the use of functions.
\\nGiven our setup, we can create factories for our models. They won\'t do much yet, but let\'s get them set up anyway.
\\nimport Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst base = {\\n id: 1,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n Factory.prepare(this, Author, base);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst base = {\\n id: 1,\\n title: \'Post title\',\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n Factory.prepare(this, Post, base, traits);\\n }\\n}\\n
Notice how we\'re using the prepare
method to set up our factories. We\'re passing the factory itself, the model it\'s supposed to create, the base data for the model, and any traits we want to apply to the base data. As Author
is a little simpler for the time being, we only need to pass the base data, skipping the traits entirely.
Now that we have our factories set up, we can start building objects with them. We\'ll add a static build
method to our Factory
class that will take any number of traits and apply them to the base data.
export default class Factory {\\n // ...\\n\\n static build(...desiredTraits) {\\n const factoryName = this.name;\\n const { model, base, traits } = Factory.factoryMap.get(factoryName);\\n\\n // Merge the base and traits\\n const data = Object.assign(\\n {},\\n base,\\n ...desiredTraits.map((trait) => traits[trait])\\n );\\n\\n return new model(data);\\n }\\n}\\n
What we\'ve done is fairly simple, as you can probably tell. As the this
context of the function refers to the factory subclass, we can use this.name
to get the factory name, look up the factory in the registry, and merge the base data with any desired traits. We then pass the resulting data to the model\'s constructor and return the new object.
const post = PostFactory.build();\\n// Post { id: 1, title: \'Post title\', content: \'Post content\' }\\n\\nconst publishedPost = PostFactory.build(\'published\');\\n// Post {\\n// id: 1,\\n// title: \'Post title\',\\n// content: \'Post content\',\\n// publishedAt: 2025-01-02T00:00:00.000Z\\n//}\\n
Our traits are nice and all, but it\'s inefficient to have to define a trait for every special case in our codebase. While they provide the benefit of composition, they can still end up becoming a bit unwieldy.
\\nTo solve this, we can allow the build
method to accept objects and functions. Given an object, the method will merge it with the base data. Given a function, it will call the function with the base data and return the result.
export default class Factory {\\n // ...\\n\\n static build(...desiredTraits) {\\n const factoryName = this.name;\\n const { model, base, traits } = Factory.factoryMap.get(factoryName);\\n\\n const data = { ...base };\\n\\n // Merge the base and traits\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n }\\n}\\n
What can we do with it? Well, we can create very specific objects, as needed. For example, let\'s pass a custom title to our post and a function to generate a random email for our author.
\\nconst post = PostFactory.build(\'unpublished\', { title: \'Custom title\' });\\n// Post {\\n// id: 1,\\n// title: \'Custom title\',\\n// content: \'Post content\',\\n// publishedAt: null\\n// }\\n\\nconst author = AuthorFactory.build(\\n { name: \'John\' },\\n data => ({ email: `${data.name.toLowerCase()}@authornet.io` }\\n);\\n// Author {\\n// id: 1,\\n// name: \'John\',\\n// surname: \'Authorson\',\\n// email: \'john@authornet.io\'\\n// }\\n
Some of you might be wondering why I didn\'t choose to make the customized object contain functions, so that email
can be specified as a function, instead of passing two parameters. This is a design choice I stand by, as the cost of calling a function for each property can easily pile up. Instead, most of the time, we can get away with passing a function that generates multiple properties at once. At most, we\'ll end up with an object and a function for each factory call.
In some cases, we might want to clear out objects, to make sure they don\'t interfere with our tests. This is especially useful when we\'re counting records or checking relationships, for example.
\\nWe can add two new methods, clear
and clearAll
, to our Factory
class to handle this. These methods will simply access the static variables (instances
, indexedInstances
and getterCache
) of the Model
class and reset them.
import Model from \'#src/core/model.js\';\\n\\nexport default class Factory {\\n // ...\\n\\n static clear(model) {\\n Model.instances[model] = [];\\n Model.indexedInstances[model] = new Map();\\n Model.getterCache[model] = {};\\n }\\n\\n static clearAll() {\\n Model.instances = {};\\n Model.indexedInstances = {};\\n Model.getterCache = {};\\n }\\n}\\n
And here they are in action, clearing instances created by the PostFactory
.
PostFactory.build();\\nPost.all.length; // 1\\nFactory.clear(\'Post\');\\nPost.all.length; // 0\\n
As you\'re well aware by this point, convenience methods are a staple of my coding style. I definitely dislike having to find the appropriate factory to call every time I need to create an object. I\'d much rather call a method on the Factory
class itself, specifying the model I want to create.
This requires a little bit of setup first. We\'ll have to add a modelMap
to our Factory
class, which will allow us to look up the factory for a given model. And, instead of having a static build
method on the Factory class, we\'ll make sure to define it per factory subclass.
export default class Factory {\\n static modelMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n // Store the factory in the model map\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...base };\\n\\n // Merge the base and traits\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n }\\n });\\n }\\n\\n // Remove the static `build` method\\n}\\n
So far, we\'ve ended up exactly where we were before. Only difference is that we\'ve moved the build
method to the factory subclass, using Object.defineProperty()
, which allows us to define a property on the factory subclass. Notice how we can use the factory
variable to pass the subclass and the value
descriptor to define the method (in contrast with the get
descriptor we used before).
Let\'s go ahead and add our convenience methods now. We\'ll add a build
method to the Factory
class that will look up the appropriate factory for the model and call the build
method on it. And, while we\'re at it, let\'s add a buildArray
method that will allow us to create an array of objects.
export default class Factory {\\n // ...\\n\\n static build(modelName, ...desiredTraits) {\\n return Factory.modelMap.get(model).build(...desiredTraits);\\n }\\n\\n static buildArray(modelName, count, ...desiredTraits) {\\n return Array.from({ length: count }, () =>\\n Factory.build(model, ...desiredTraits)\\n );\\n }\\n}\\n
With these methods in place, we can now create multiple objects at once, and all from the Factory
class itself.
const author = Factory.build(\'Author\', { email: \'\' });\\n// Author { id: 1, name: \'Author\', surname: \'Authorson\', email: \'\' }\\n\\nconst posts = Factory.buildArray(\'Post\', 3, { content: null }, \'unpublished\');\\n// [\\n// Post { id: 1, title: \'Post title\', content: null, publishedAt: null },\\n// Post { id: 1, title: \'Post title\', content: null, publishedAt: null },\\n// Post { id: 1, title: \'Post title\', content: null, publishedAt: null }\\n// ]\\n
As you may have noticed in the previous example, we\'re always creating objects with the same id
. This is fine for most cases, but it can become a problem when we need to test objects with unique identifiers. This is where sequences come in. Sequences are a way to generate unique values for our objects.
Ok, we\'re going in the deep end now. We need a way to easily define a sequence inside a factory subclass and assign it to the base data. The sequence must generate a new value each time, yet it must be passed into the base
definition. This is a bit tricky, but we can do it, using some advanced JavaScript features.
After trying a few different approaches, I\'ve settled on hiding the complexity behind the prepare
method, while exposing a static sequence
method on the factory subclass. This method will return something, which can then be picked up by the prepare
method and set up an appropriate build
method for the subclass.
What sort of something should it return? is the million dollar question. I\'ve landed yet again on using the Symbol
built-in object. This is because Symbol
is unique and immutable, and it can be used as a key in an object. This allows us to return an object with a unique characteristic that we can then easily look up in the prepare
method.
const sequenceSymbol = Symbol(\'sequence\');\\n\\n// ...\\n
Notice how we keep the Symbol
outside of the class and we don\'t expose it in any way. This means that this is an immutable, unique value that can only ever be known to the Factory
class. This way, we ensure no one can mess with our sequences.
Let\'s go ahead and define the sequence
method on our factory subclass now.
// ...\\n\\nconst isSequence = value =>\\n value && typeof value[sequenceSymbol] === \'function\';\\n\\nexport default class Factory {\\n // ...\\n\\n static sequence = (fn = n => n) => {\\n let i = 0;\\n const sequence = () => fn(i++);\\n return { [sequenceSymbol]: sequence };\\n };\\n}\\n
A simple closure does the trick here. We\'ll allow the caller to pass a function to transform the sequence value, and we\'ll return an object with a [sequenceSymbol]
key that contains the sequence function. This way, we can easily look up the sequence function in the prepare
method, using our new isSequence
helper function.
If you\'re unfamiliar with JavaScript\'s closures or simply need a refresher, I highly recommend reading this article on the subject.
\\nFinally, we can update the prepare
method to look for sequences in the base data and apply them to the object.
export default class Factory {\\n // ...\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n const factoryBase = {};\\n\\n Object.keys(base).forEach(key => {\\n const value = base[key];\\n const getter = isSequence(value) ? value[sequenceSymbol] : () => value;\\n Object.defineProperty(factoryBase, key, {\\n get: getter,\\n enumerable: true,\\n });\\n });\\n\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...factoryBase };\\n\\n // Merge the base and traits\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n },\\n });\\n }\\n}\\n
That looks fairly complex at this point. Let\'s unpack the changes we\'ve made. Using the isSequence
helper function, we can check if a key in the base
is a sequence. In that case, we\'ll use the function returned by the sequence to generate the value. Otherwise, we\'ll use the value as is.
In order to make this work, we\'ll have to build a new base from the base
. However, using Object.defineProperty()
will normally return non-enumerable values, which will break our spread operation (...
) later down the line. to fix that, we\'ll have to use enumerable: true
to make sure the properties are enumerable.
I understand that there may be a need to use sequences in traits, too. However, I\'ve decided to keep things simple for now. If you need sequences in your traits, it\'s relatively easy to implement this functionality yourself. Give it a go, I\'m sure you\'ll find it\'s a fun challenge!
\\nFinally, we can go ahead and use our sequences in our factories. Let\'s update our PostFactory
to include a sequence for the id
. While we\'re at it, we can also add a sequence for the title
field, too. We\'ll also update AuthorFactory
to include a sequence for the id
, too.
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Post #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n Factory.prepare(this, Post, base, traits);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst idSequence = Factory.sequence();\\n\\nconst base = {\\n id: idSequence,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n Factory.prepare(this, Author, base);\\n }\\n}\\n
With these changes in place, we can now create objects with unique attribute values.
\\nconst author = AuthorFactory.build();\\nconst posts = Factory.buildArray(\\n \'Post\', 3, \'unpublished\', { authorId: author.id }\\n);\\n// [\\n// Post { id: 0, title: \'Post #0\', authorId: 0 },\\n// Post { id: 1, title: \'Post #1\', authorId: 0 },\\n// Post { id: 2, title: \'Post #2\', authorId: 0 }\\n// ]\\n
Notice how all of our posts have unique identifiers and titles, as they use the sequence we\'ve defined in the factory. Oh, and I\'ve snuck in a relationship in this example, too. This last bit worked all along, but we never cared to check, as we were too busy building our factories!
\\nWe\'ve come a long way in this series. We\'ve built models, queries, scopes, serialization, and now factories. This latest system allows us to easily create complex objects for our tests, and we\'ve even added sequences to generate unique values for our objects. Oh, and customization is a breeze, too!
\\nThere are a few bits and pieces that I\'ve implemented or have left out in previous implementation, as well as some problems you may face in the real world using such a setup. Stay tuned for future installments, where I\'ll cover these topics in more detail!
\\nLast but not least, here\'s a summary of the complete implementation. This includes all new classes, as well as previous one, so you can pick up where you left off next time.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n Object.entries(\\n Object.getOwnPropertyDescriptors(model.prototype),\\n ).forEach(([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n });\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].get(id);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
export default class Serializer {\\n static prepare(serializer, serializableAttributes) {\\n serializer.serializableAttributes = [];\\n\\n serializableAttributes.forEach((attribute) => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n if (typeof alias === \\"string\\") return this.subject[alias];\\n if (typeof alias === \\"function\\")\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map((subject) => this.serialize(subject, options));\\n }\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nconst sequenceSymbol = Symbol(\'sequence\');\\n\\nconst isSequence = value =>\\n value && typeof value[sequenceSymbol] === \'function\';\\n\\nexport default class Factory {\\n static factoryMap = new Map();\\n static modelMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n const factoryBase = {};\\n\\n Object.keys(base).forEach(key => {\\n const value = base[key];\\n const getter = isSequence(value) ? value[sequenceSymbol] : () => value;\\n Object.defineProperty(factoryBase, key, {\\n get: getter,\\n enumerable: true,\\n });\\n });\\n\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...factoryBase };\\n\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n },\\n });\\n }\\n\\n static sequence = (fn = n => n) => {\\n let i = 0;\\n const sequence = () => fn(i++);\\n return { [sequenceSymbol]: sequence };\\n };\\n\\n static build(model, ...desiredTraits) {\\n return Factory.modelMap.get(model).build(...desiredTraits);\\n }\\n\\n static buildArray(model, count, ...desiredTraits) {\\n return Array.from({ length: count }, () =>\\n Factory.build(model, ...desiredTraits)\\n );\\n }\\n\\n static clear(model) {\\n Model.instances[model] = [];\\n Model.indexedInstances[model] = new Map();\\n Model.getterCache[model] = {};\\n }\\n\\n static clearAll() {\\n Model.instances = {};\\n Model.indexedInstances = {};\\n Model.getterCache = {};\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n this.authorId = data.authorId;\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.name = data.name;\\n this.surname = data.surname;\\n this.email = data.email;\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst idSequence = Factory.sequence();\\n\\nconst base = {\\n id: idSequence,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n Factory.prepare(this, Author, base);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Post #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n Factory.prepare(this, Post, base, traits);\\n }\\n}\\n
Last updated: January 2, 2025 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series, following Modeling complex JavaScript object serialization. It may still make sense on its own, but it\'s highly recommended to read the previous articles first. This series is more of a show & tell with the aim to inspire you to…","guid":"https://www.30secondsofcode.org/js/s/complex-object-factories","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-01-01T16:00:00.364Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/tranquil-desktop-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/tranquil-desktop-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object serialization","url":"https://www.30secondsofcode.org/js/s/complex-object-serialization","content":"This article is part of a series, picking up where Modeling complex JavaScript object scopes left off. Make sure to read previous articles in the series before continuing. The entire series is more of a show & tell, aiming to inspire you to use more advanced JavaScript features and patterns in your projects.
\\nIn the last installment, we covered a lot of ground towards making our model queries reusable. Our Model
and RecordSet
classes are now capable of handling complex queries, and we can even compose them in a way. Our two models, Post
and Author
are now fully functional and our code is well-optimized. However, we still have one problem to solve - serialization.
As usual, before diving into the code, let\'s check the directory structure of our project. This time around, we\'re adding a new class, Serializer
, as well as a new directory, called serializers
.
src/\\n├── core/\\n│ ├── model.js\\n│ ├── recordSet.js\\n│ └── serializer.js\\n├── models/\\n│ ├── author.js\\n│ └── post.js\\n└── serializers/\\n ├── postSerializer.js\\n └── postPreviewSerializer.js\\n
If you need a code refresher without going into all the details, check out the code summary from the previous article.
\\nIf you\'re not familiar, serialization is the conversion of an object into a format that can be stored or transmitted. In our case, we want to convert our Post
and Author
objects into a format that can be sent to the client. This format is usually JSON.
I\'m not exactly sure how common my mileage is in this matter, but I\'m quite used to serializers being somewhat separate from models. Oftentimes, a serializer will be akin to a view into the model, rather than a one-to-one representation. This is especially true, when design patterns such as decorators or presenters come into play, completely transforming the data.
\\nTo that effect, my approach to serialization is a very flexible one. A serializer should only be given a subject to serialize and an options object. Then, given some standard rules and configuration of attributes, it should be able to produce the desired result without a lot of fuss or poking around too much in the underlying structure.
\\nI\'m pretty sure the original inspiration for this approach comes from the active_model_serializers
Ruby gem for Rails. Notice that I\'m simply drawing inspiration from it, not trying to replicate it exactly in JavaScript.
In this particular example, we\'ll be working with two serializers essentially for the same model, Post
. The first one, PostSerializer
, will be used to serialize a full post, while the second one, PostPreviewSerializer
, will be used to serialize a preview of a post, for example as part of a list of posts.
const author = new Author({\\n id: 1,\\n name: \'John\',\\n surname: \'Doe\',\\n email: \'j.doe@authornet.io\'\\n});\\n\\nconst post = new Post({\\n id: 1,\\n title: \'Hello, World!\',\\n content: \'Lorem ipsum dolor sit amet.\',\\n publishedAt: new Date(\'2024-12-01\') ,\\n authorId: 1\\n});\\n\\n// Sample of a serialized post (PostSerializer)\\n// {\\n// title: \'Hello, World!\',\\n// content: \'<p>Lorem ipsum dolor sit amet.</p>\',\\n// date: \'Sun, Dec 01, 2024\',\\n// author: {\\n// name: \'John Doe\',\\n// email: \'j.doe@authornet.io\'\\n// }\\n// }\\n\\n// Sample of a serialized post preview (PostPreviewSerializer)\\n// {\\n// title: \'Hello, World!\',\\n// date: \'Dec 01, 2024\',\\n// author: \'John Doe\',\\n// url: \'/posts/1\'\\n// }\\n
Each model has its own attributes, which, if you remember, are getter functions. As these attributes are easily accessible and often cached in memory, we can outline which ones we want to serialize.
\\nTo create our Serializer
class, we\'ll start by defining a simple constructor
. It will accept a subject
to serialize and an options
object. Via the options object, we can pass additional configuration, which can be used later down the line. For now, we\'ll just store the subject and options in the class instance.
export default class Serializer {\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n}\\n
Before we can do anything meaningful with this class, we\'ll need to define a way to dictate the serializable attributes. Drawing inspiration from previous implementations, we can utilize our old friend, the static initialization block. Using this trick, we can define a prepare
function, much like we did with the Model
class.
But what will this function do? you may be asking. Simply put, it will accept the serializer subclass and an array of attribute names. Then, it will store these attributes in a static property of the subclass. This way, we can easily access them later on.
\\nexport default class Serializer {\\n static prepare(serializer, attributes) {\\n serializer.serializableAttributes = attributes;\\n }\\n\\n // ...\\n}\\n
Looks simple, right? Let\'s go ahead and define a serialize
method, which will return an object with the serialized attributes. We\'ll use the serializableAttributes
property to filter out the attributes we want to serialize.
export default class Serializer {\\n // ...\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this.subject[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
In this post, I won\'t be covering the conversion to a JSON string, as it\'s a trivial task, using JSON.stringify()
. Instead, I\'ll focus on the structure of the serialized object.
Notice the use of this.constructor
instead of Serializer
. This is because we want to access the static property of the subclass, not the core serializer class. We then retrieve the attribute value from the subject and store it in the accumulator object.
With all of this boilerplate out of the way, we can finally define our PostSerializer
. For starters, we\'ll have it return the title
, content
and publishedAt
attributes of a post.
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\'title\', \'content\', \'publishedAt\']);\\n }\\n}\\n
Putting it to the test, we can now serialize a post. Sure, it\'s not much, but it\'s a start. Notice how the specified attributes are serialized exactly as defined in the Post
model.
// Considering the post object from the previous example\\n\\nnew PostSerializer(post).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// content: \'Lorem ipsum dolor sit amet.\',\\n// publishedAt: \'2024-12-01T00:00:00.000Z\'\\n// }\\n
Sometimes, it\'s necessary to rename attributes when serializing them. One could argue that you can simply add more attribute getters to your models and that would work to an extent, but it would make the models unwieldy and harder to maintain.
\\nInstead, we can introduce the concept of aliases. Aliases will be provided as a two-element array, where the first element is the name of the serialized attribute and the second element is the name of the attribute getter.
\\nLet\'s modify the Serializer
\'s prepare
method to account for that.
export default class Serializer {\\n static prepare(serializer, attributes) {\\n serializer.serializableAttributes = [];\\n\\n attributes.forEach(attribute => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n return this.subject[alias];\\n },\\n });\\n });\\n }\\n\\n // ...\\n}\\n
There are a few things to unpack here. The prepare
method can accept a mix of strings and arrays. If an array is detected, the first element is used as the serialized attribute name, while the second element is used as the attribute getter name. If no second element is provided, the serialized attribute name is used as the getter name.
But the fun part comes later in the function, where Object.defineProperty()
is used to define a getter for the attribute. If an alias is detected, the getter will return the value of the alias attribute, otherwise it will return the value of the attribute itself.
As you may have noticed, this change doesn\'t really affect the seralize
method just yet. We\'ll have to make an adjustment there, too. Instead of relying on the subject
, we now have each of the serializableAttributes
as a getter on the serializer instance.
export default class Serializer {\\n // ...\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
Finally, we can go ahead and update PostSerializer
with the new alias feature. We\'ll rename the publishedAt
attribute to date
.
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n \'content\',\\n [\'date\', \'publishedAt\']\\n ]);\\n }\\n}\\n
// Considering the post object from the previous examples\\n\\nnew PostSerializer(post).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// content: \'Lorem ipsum dolor sit amet.\',\\n// date: \'2024-12-01T00:00:00.000Z\'\\n// }\\n
Expanding upon the alias system, one could easily imagine a scenario where you\'d want to serialize an attribute that doesn\'t exist on the model. This could be a calculated attribute, a combination of other attributes, or even a completely unrelated value. It may also be a value that requires some additional processing before being serialized, or even be dependent on the options passed to the serializer.
\\nSame as before, let\'s update our prepare
method to handle a second argument that is a function. This function will be called with the serializer\'s subject and options, and should return the value to be serialized.
export default class Serializer {\\n static prepare(serializer, attributes) {\\n serializer.serializableAttributes = [];\\n\\n attributes.forEach(attribute => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias)\\n return this.subject[attributeName];\\n if (typeof alias === \'string\')\\n return this.subject[alias];\\n if (typeof alias === \'function\')\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n // ...\\n}\\n
Luckily, this time around, our serialize
method doesn\'t need any changes. We can now define a custom attribute in our PostSerializer
. Let\'s do that for our date
attribute, which will format the publishedAt
attribute into a human-readable date. While we\'re at it, let\'s wrap our content
in a <p>
tag, too.
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }]\\n ]);\\n }\\n}\\n
// Considering the post object from the previous examples\\n\\nnew PostSerializer(post).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// content: \'<p>Lorem ipsum dolor sit amet.</p>\',\\n// date: \'Sun, Dec 1, 2024\'\\n// }\\n
Alright, cool, but we also want to include the author\'s information in there, too. As you may remember, the Author
is a separate model, and the author
attribute of the Post
model is a relationship. We shouldn\'t have any trouble using it to serialize the author\'s name and email. But wait! The Author
has a fullName
attribute, which we\'d like to use instead of name
and surname
separately.
Let\'s spice it up even more, by making sure that the email depends on the options passed to the serializer. If the showEmail
option is set to true
, we\'ll include the email in the serialized object, otherwise we\'ll just include the name.
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
Looks cool, right? Let\'s see it put to action in an example.
\\n// Considering the post object from the previous examples\\n\\nnew PostSerializer(post).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// content: \'<p>Lorem ipsum dolor sit amet.</p>\',\\n// date: \'Sun, Dec 1, 2024\',\\n// author: {\\n// name: \'John Doe\'\\n// }\\n// }\\n\\nnew PostSerializer(post, { showEmail: true }).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// content: \'<p>Lorem ipsum dolor sit amet.</p>\',\\n// date: \'Sun, Dec 1, 2024\',\\n// author: {\\n// name: \'John Doe\',\\n// email: \'j.doe@authornet.io\'\\n// }\\n// }\\n
Serializers don\'t have a cache, as you may be aware. Be sure to move as much of the heavy lifting to your model\'s attribute getters as possible. This way, you can avoid repeating costly calculations and keep your performance in check.
\\nAs we\'ve already seen, the serializer itself is not tied to the model. This means we can create another serializer with similar attributes, but different underlying implementations. Let\'s create a PostPreviewSerializer
that will serialize a post preview, as shown in the example at the beginning of the article.
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
// Considering the post object from the previous examples\\n\\nnew PostPreviewSerializer(post).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// date: \'Dec 1, 2024\',\\n// author: \'John Doe\'\\n// url: \'/posts/1\'\\n// }\\n
Wow, that really was fast! All of this setup really paid off. Notice how we couldn\'t have had the two serializers spit out these results without all the hard work that went into aliases and custom attributes. If we hadn\'t set these up, we would only have been able to have one date
format or a single author
structure via the model\'s calculated attributes. Oh, and forget customization via options!
Yes, technically, we could have hacked together a solution with different calculated attributes and just used the simple aliases or transformed the result afterwards, but all of this is far too much hassle. Besides, it\'s not very DRY or maintainable.
\\nYou\'re probably used to it by now, but I really love me some convenience methods. This time around, we\'ll keep it simple, adding two static methods to the Serializer
class, serialize
and serializeArray
. The former will create a new instance of the serializer and call the serialize
method, while the latter will do so for an array of subjects.
export default class Serializer {\\n // ...\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map(subject => this.serialize(subject, options));\\n }\\n}\\n
I don\'t suppose it\'s necessary to show you how to use these methods, as they\'re pretty straightforward. All they do is provide us with some syntactic sugar, so we don\'t have to create a new instance of the serializer every time we want to serialize something.
\\nThis entry of the series was a little bit off the Rails (get it? because it\'s not so much about Ruby on Rails... never mind). While it may not seem like much, we\'ve made a huge leap in terms of reusability and flexibility. Our serializers are capable of handling complex attribute serialization, aliases, custom attributes, and even options. They can also hide a lot of complexity under the hood, so we don\'t need to dive into details.
\\nAs the codebase of this series is starting to get larger and more complex, we might need to address topics such as loading everything and testing the code. The next installments in the series will focus on these topics, so stay tuned!
\\nYou didn\'t think I\'d leave you without a code reference for this article, did you? Make sure to bookmark this for future reference, as we\'ll need it in the next installment.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n Object.entries(\\n Object.getOwnPropertyDescriptors(model.prototype),\\n ).forEach(([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n });\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].get(id);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
export default class Serializer {\\n static prepare(serializer, serializableAttributes) {\\n serializer.serializableAttributes = [];\\n\\n serializableAttributes.forEach((attribute) => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n if (typeof alias === \\"string\\") return this.subject[alias];\\n if (typeof alias === \\"function\\")\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map((subject) => this.serialize(subject, options));\\n }\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n this.authorId = data.authorId;\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.name = data.name;\\n this.surname = data.surname;\\n this.email = data.email;\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
Last updated: December 26, 2024 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series, picking up where Modeling complex JavaScript object scopes left off. Make sure to read previous articles in the series before continuing. The entire series is more of a show & tell, aiming to inspire you to use more advanced…","guid":"https://www.30secondsofcode.org/js/s/complex-object-serialization","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-12-25T16:00:00.818Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/coffee-phone-tray-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/coffee-phone-tray-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object scopes","url":"https://www.30secondsofcode.org/js/s/complex-object-scopes","content":"This article is part of a series and picks up where the previous article, Modeling complex JavaScript object attributes & relationships, left off. If you haven\'t read it yet, I recommend you do so before continuing. The entire series is more of a show & tell, aiming to inspire you to use more advanced JavaScript features and patterns.
\\nIn the previous two installments, we\'ve covered the core of our implementation, with a Model
and RecordSet
class, as well as two models, Author
and Post
. We\'ve also implemented basic querying, attributes and relationships. This time around, we\'ll focus on object scoping, a way to quickly retrieve a subset of objects from a collection.
Before we dive into the code, let\'s take a look at the current directory structure:
\\nsrc/\\n├── core/\\n│ ├── model.js\\n│ └── recordSet.js\\n└── models/\\n ├── author.js\\n └── post.js\\n
This time around, we won\'t be making any changes to the structure, but rather to the existing files. Let\'s get started!
\\nAgain, you can find an implementation refresher in the code summary of the previous article. We\'ll be building on top of that codebase in this article.
\\nActiveRecord has a concept called scopes, which are predefined queries that can be reused. These are usually defined in a model and can be chained together to create more complex queries. They provide convenient ways to make your code DRY (Don\'t Repeat Yourself) and more reusable.
\\nDue to the way, we\'ve implemented our querying system, scopes can be easily defined, yet the syntax won\'t be too similar to that of ActiveRecord. I honestly don\'t mind this too much, due to the fact that it may make searching for scope usage a little easier.
\\nFirst things first, however, let\'s define a scope for our Post
model. The obvious use case here is to find posts that are published. If you recall, this can be done by checking the publishedAt
attribute.
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n static published(records) {\\n const now = new Date();\\n return records.where({ publishedAt: d => d < now });\\n }\\n}\\n
You may have noticed that I\'m not relying on the this
keyword, nor am I using the model to retrieve the records, which may strike you as strange. I\'ll come back round to explain this design choice in due time.
If you were to explain what this scope does, you\'d definitely use the word filtering. While the distinction isn\'t necessarily an important one, it\'s better to build step-by-step understanding. Let\'s see this filter in action:
\\nconst posts = [\\n new Post({ id: 1, publishedAt: new Date(\'2024-12-01\') }),\\n new Post({ id: 2, publishedAt: new Date(\'2024-12-15\') }),\\n new Post({ id: 3, publishedAt: new Date(\'2024-12-20\') }),\\n];\\n\\n// Supposing the current date is 2024-12-19\\nconst publishedPosts = Post.published(Post.all);\\n// [ Post { id: 1 }, Post { id: 2 } ]\\n
Apart from filtering records, we may also want to sort them. Before we do that, however, I\'d like to add an order
method to our RecordSet
class. It\'s not much more than an alias for Array.prototype.sort()
, but I prefer naming things explicitly. This way we can search for record set operations more easily in larger codebases, instead of deciphering the type of the caller.
export default class RecordSet extends Array {\\n // ...\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n}\\n
Notice that this order
method can subtly handle plain arrays and RecordSet
s. This subtlety may come in handy when combined with pluck
or Array.prototype.map()
and can save us from a few headaches. We can also expose this method in the Model
class, same as we did with where
.
export default class Model {\\n // ...\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n}\\n
Now that we have defined the order
method, let\'s define a scope for our Post
model that sorts posts by their publishedAt
attribute, newest first.
export default class Post extends Model {\\n // ...\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n}\\n
This scope isn\'t a filtering scope, but rather a sorting one. We expect the same amount of records back, but in a different order. Let\'s see this scope in action:
\\n// Consider the posts from the previous sample and the same current date\\nconst newestPosts = Post.byNew(Post.all);\\n// [ Post { id: 3 }, Post { id: 2 }, Post { id: 1 } ]\\n
Now that we have some scopes defined, we can try chaining them together. This will allow us to create more complex queries by combining multiple scopes. As scopes are named functions, complex logic can be collapsed into a few keywords, helping future maintainers understand the code more easily.
\\nChaining two scopes is relatively simple. We need only call the first scope and pass the result to the second scope. Let\'s chain the published
and byNew
scopes together:
// Consider the posts from the previous samples and the same current date\\nconst publishedPosts = Post.published(Post.all);\\n// [ Post { id: 1 }, Post { id: 2 } ]\\nconst newestPublishedPosts = Post.byNew(publishedPosts);\\n// [ Post { id: 2 }, Post { id: 1 } ]\\n
Ok, this is all well and good, but it\'s not particularly sustainable. Suppose we had half a dozen scopes, readability would quickly deteriorate, dragging maintainability down with it. We can do better!
\\nIf you noticed that the strange decisions to pass a records
argument to the scopes, you\'re about to find out why. This decision allows us to create a simpler chaining system in the Model
class itself. All we\'ll need is a scope
method that takes a list of scope names and applies them in order.
export default class Model {\\n // ...\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n}\\n
Finally, this
comes into play. Remember that in the context of a static
method in the model, it refers to the calling class, i.e. the model itself. This way, all scopes can start with all
records and build up from there.
Let\'s see how this new scope
method can make the previous example less verbose:
// Consider the posts from the previous samples and the same current date\\nconst newestPublishedPosts = Post.scope(\'published\', \'byNew\');\\n// [ Post { id: 2 }, Post { id: 1 } ]\\n
Under the hood, the exact same code is executed, but we\'ve made it easier to read and search for. This is a good example of how a small change can make a big difference in the long run.
\\nThere may be advanced cases, where you\'d want to specify the records the scope is applied on, instead of the entire collection. Implementing a default scope to replace all
is a relatively easy customization you can make yourself to handle such cases. Additionally, models can redefine all
themselves, if you feel like this is a better approach for your use case.
Before we wrap this up, I\'d like to make some minor adjustments around the codebase. In the published
scope, we didn\'t use the isPublished
calculated attribute, but relied on the publishedAt
data attribute.
This might be prudent in some cases, as the current date may be slightly different for different records. However, in most cases, we\'d use the calculated attribute, as it\'s less work and milliseconds rarely matter. Let\'s adjust the published
scope accordingly:
export default class Post extends Model {\\n // ...\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n}\\n
This change is minor and seems like we\'re optimizing the code, but we\'re rather making it a little slower, if anything. Why is that? If you remember from the previous article, the isPublished
attribute is calculated from some data that exists on the model. The data attribute is essentially persisted in memory, while the calculated one isn\'t. This can come to bite us for more complex operations, larger datasets, or more frequent calls. Let\'s fix it!
We can cache calculated attributes, same as we\'ve done for model instances before. The Model
class can hold this cache, seamlessly populate and use it as needed. Remember that all of our data is considered immutable, so a cache will be safe to use.
export default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n Object.entries(Object.getOwnPropertyDescriptors(model.prototype)).forEach(\\n ([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n }\\n );\\n }\\n // ...\\n}\\n
This code looks intimidating even to me, not gonna lie. But it\'s the only change we\'ll have to make. In the prepare
method, we now create a WeakMap
for each getter function on the model. This map will cache the calculated attribute for each instance, making subsequent calls faster.
But how? you may be asking. We use Object.getOwnPropertyDescriptors()
to find all the getters on the model, by checking each descriptor\'s get
property. If it is a function, we first create a WeakMap
. If you\'re not familiar with this data structure, it\'s a map that doesn\'t prevent garbage collection of its keys. This is perfect for our use case, as we don\'t want to keep instances alive just because they have a calculated attribute and we may need to conserve memory.
Then, we redefine the getter function itself. This new getter function will first check if the instance has a cached value for the attribute. If it doesn\'t, it will use the descriptor\'s get
property to call the original getter function, calculate the value and cache it, using the record as the key.
Quite the elaborate party trick, right? Despite the complexity, this change allows us to calculate attributes only once per instance, which can be a huge performance boost for larger datasets or more complex calculations. And the best part is, we don\'t need to change anything on any of our models, as this works automatically!
\\nI use the term once per instance a little liberally here. In fact, due to garbage collection, we have little control over how long the cache will be kept alive. However, for most data-intensive and repeatable operations, this cache will be a huge performance boost.
\\nAs per previous installments, we continue our journey to implement an ActiveRecord-like pattern in JavaScript. This time around, we\'ve focused on making repeated queries more efficient and easier to read. We\'ve implemented scopes, which are predefined queries that can be chained together to create more complex queries. We\'ve also optimized our code by caching calculated attributes, which can be a huge performance boost for larger datasets or more complex calculations.
\\nWhile the core of the project is starting to take shape, we\'ve yet to address some more advanced features, which we\'ll cover in future installments. Stay tuned for more and keep on coding!
\\nAs per tradition, the complete implementation up until this point can be found below. This is a good place to pick up from in future installments.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n Object.entries(\\n Object.getOwnPropertyDescriptors(model.prototype),\\n ).forEach(([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n });\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].get(id);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n this.authorId = data.authorId;\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.name = data.name;\\n this.surname = data.surname;\\n this.email = data.email;\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
Last updated: December 19, 2024 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series and picks up where the previous article, Modeling complex JavaScript object attributes & relationships, left off. If you haven\'t read it yet, I recommend you do so before continuing. The entire series is more of a show & tell, aimi…","guid":"https://www.30secondsofcode.org/js/s/complex-object-scopes","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-12-18T16:00:00.274Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/digital-nomad-13-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/digital-nomad-13-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object attributes & relationships","url":"https://www.30secondsofcode.org/js/s/complex-object-attributes-relationships","content":"This is the second article in a series about a recent project I implemented, inspired by Ruby on Rails\' ActiveRecord pattern. It\'s more of an advanced show & tell, aiming to inspire readers to use more advanced JavaScript features and patterns. If you haven\'t read the first article, I recommend you start there: Modeling complex JavaScript object collections in memory.
\\nIn the previous article, we explored the core of modeling complex object collections in memory, by setting up the basis for a Model
and RecordSet
, which can help us manage and interact with our data. In this article, we\'ll continue our journey, focusing on attributes and relationships between models. We\'ll also make some optimizations to our implementation to improve performance towards the end.
As mentioned previously, the directory structure for our project is fairly simple. This time, we\'ll be adding a new model in our models
directory, along with some code in the existing core
directory files. Here\'s what the new structure looks like:
src/\\n├── core/\\n│ ├── model.js\\n│ └── recordSet.js\\n└── models/\\n ├── author.js\\n └── post.js\\n
If you need a refresher on the various implementations, you can check out the code summary from the previous article.
\\nIn the ActiveRecord pattern, models have attributes that represent the columns in the database. Models can also define methods which act very similarly to attributes, allowing us to retrieve data that may be the result of more complex operations on the model\'s data.
\\nImplementing this in JavaScript isn\'t particularly hard, but we may want to consider - yet again - the often underused JavaScript getter functions to seamlessly access object properties and methods alike. This decisions will also come in handy later.
\\nInstead of modifying our Post
model from before, let\'s create a brand new Author
model. The model will contain information about an author, such as a name, surname, and email address. We\'ll also store an id
for all our models (we\'ve already done this for the Post
model). We\'ll come back to that in a minute.
Here\'s what the Author
model looks like:
import Model from \'#src/core/model.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.name = data.name;\\n this.surname = data.surname;\\n this.email = data.email;\\n }\\n}\\n
This simple model definition has given us access to the id
, name
, surname
, and email
attributes. We will call these data attributes to differentiate them from calculated attributes, which we\'ll be defining next.
This is a semantics distinction rather than anything else. I only make this distinction to help the reader understand that some attributes are stored in the object itself (data attributes) while others are calculated from the object\'s data (calculated attributes).
\\nNow that we have a model with some data attributes, let\'s add a calculated attribute to our Author
model. For this instance, we want a fullName
attribute that concatenates the name
and surname
attributes.
To achieve this, we can use a getter function in the Author
model:
import Model from \'#src/core/model.js\';\\n\\nexport default class Author extends Model {\\n // ...\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n}\\n
Data attributes are quite versatile and allow us to hide complexity behind seemingly simple properties. In this example, we take care to handle the case where the surname
is empty, as we consider it optional for this model.
Other cases may require comparison with external data or data that is time-sensitive. Let\'s look at our Post
model for an example of a calculated attribute that depends on the current date. We\'ll first update its constructor to accept a publishedAt
attribute.
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n }\\n}\\n
Then, we can create a calculated attribute to check if the post is published. To do this, we\'ll compare the publishedAt
attribute with the current date and return a boolean value.
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n}\\n
In the ActiveRecord pattern, models can have relationships with other models. These relationships can be one-to-one, one-to-many, or many-to-many. One-to-one and one-to-many relationships are the most common and are relatively easy to implement, while many-to-many relationships are a little trickier to deal with.
\\nWe\'ll largely do away with these conventions and, instead simplify relationships to single and multiple. A single relationship means that a model instance references a single instance of another model, while a multiple relationship means that a model instance references multiple instances of another model.
\\nSingle relationships are simple to implement. In our case, we\'ll add a single relationship from the Post
model to the Author
, meaning that each post will have exactly one author.
To make single relationships easier, we\'ll use the id
attribute we sneaked into our models earlier. We\'ll also update our Model
class with a static
find
method, that can retrieve and return a model instance by its id
.
export default class Model {\\n // ...\\n static find(id) {\\n return this.all.find(model => model.id === id);\\n }\\n}\\n
Remember, this
in the context of this method refers to the calling class, which means that Author.find(id)
will return an Author
instance, while Post.find(id)
will return a Post
instance.
Next, we can update our Post
model to include an authorId
attribute:
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n this.authorId = data.authorId;\\n }\\n}\\n
Ok, now we have an authorId
attribute in our Post
model. But how do we get the actual Author
model from this authorId
? We can create a getter function in the Post
model to do this, using our find
method from before.
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
Technically, author
is yet another calculated attribute, but it\'s special - a calculated attribute that returns another model instance. This is the basis of a single relationship!
const author = new Author({\\n id: 1,\\n name: \'John\',\\n surname: \'Doe\',\\n email: \'j.doe@authornet.io\'\\n});\\n\\nconst post = new Post({\\n id: 1,\\n title: \'Hello, World!\',\\n content: \'This is my first post.\',\\n publishedAt: new Date(\'2024-12-08\'),\\n authorId: author.id\\n});\\n\\npost.author;\\n// Author { id: 1, name: \'John\', surname: \'Doe\', email: \'j.doe@authornet.io\' }\\npost.author.fullName; // \'John Doe\'\\n
As you may have noticed, I skipped adding the other side of the relationship in the previous section. This means that no Author
instance can easily retrieve all the posts written by that author.
This, however, is a case of a multiple relationship, where a model instance references multiple instances of another model. Author
instances also don\'t have any id
references to Post
instances. How can we solve this?
Thinking in a similar manner as before, a posts
relationship would simply be a calculated attribute. Its job is to retrieve all posts by an author. But we know the author\'s id
! And we also have a way to get all records that match specific criteria, via the where
method in the RecordSet
class!
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n // ...\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
Pretty simple, huh? With barely any extra code, we\'ve managed to create a relationship from the Author
model to the Post
model. Complex relationship chains can be built in a similar manner, by chaining calculated attributes together.
const author = new Author({\\n id: 1,\\n name: \'John\',\\n surname: \'Doe\',\\n email: \'j.doe@authornet.io\'\\n});\\n\\nconst post1 = new Post({\\n id: 1,\\n title: \'Hello, World!\',\\n content: \'This is my first post.\',\\n publishedAt: new Date(\'2024-12-08\'),\\n authorId: author.id\\n});\\nconst post2 = new Post({\\n id: 2,\\n title: \'Goodbye, World!\',\\n content: \'This is my last post.\',\\n publishedAt: new Date(\'2024-12-12\'),\\n authorId: author.id\\n});\\n\\nauthor.posts;\\n// [Post { id: 1}, Post { id: 2 }]\\n\\n// Supposing the current date is 2024-12-10\\nauthor.posts.where(post => post.isPublished);\\n// [Post { id: 1 }]\\n
I\'ve carefully avoided many-to-many relationships here, mainly to keep things neat and simple. If you were to model them, you\'d store an array of id
s in a model instance, then use a where
query to retrieve the related instances. The other side of the relationship would be pretty much the same as the posts
attribute in the Author
model, except you\'d combine your where
query with pluck
to get the related id
s.
One emergent behavior of this implementation is the ability to query models based on all their attributes, not just the data attributes. This can unlock a lot of potential for complex queries and relationships.
\\nCalculated attributes are implemented as getter functions, which means our where
method can directly query them any way we like. Same goes for pluck
and select
, as well as built-in array methods, such as Array.prototype.map()
.
// Consider the posts from the previous sample and the same current date\\nconst posts = Post.all;\\n\\nposts.where(post => post.isPublished);\\n// [Post { id: 1 }]\\n\\nposts.pluck(\'title\');\\n// [\'Hello, World!\', \'Goodbye, World!\']\\n\\nposts.map(post => post.isPublished);\\n// [true, false]\\n
Relationships can be used in queries, too. After all, they\'re simply calculated properties we\'ve given a special meaning to. This means we can query relationships just like any other attribute.
\\nconst authors = Author.all;\\n\\nauthors.where(author => author.posts.length > 1);\\n// Author { id: 1 }\\n\\nauthors.pluck(\'posts\');\\n// [[Post { id: 1 }, Post { id: 2 }]\\n\\nauthors.map(author => author.posts.length);\\n// [2]\\n
The astute reader may have noticed by this point that mostly any attribute displays the same qualities in terms of querying. This observation paves the way for relationships through intermediate models (e.g. comments belonging to a post belonging to an author can be queried through the Author
instance itself). ActiveRecord\'s :through
associations may come to mind here.
id
queriesOne thing you may have noticed is that we query records by id
quite a lot. In fact, it may be our most common operation. We\'ve already made record retrieval use a central storage on the Model
class, but what if we could do more?
What I\'ve done in my implementation is quite simple - I\'ve added a second, indexed storage, using Map
objects. Apart from instances
, I also have indexedInstances
, where each model has its own Map
and we can quickly retrieve a record by id.
For this to work, we also need to update the prepare
method, along with the constructor
of the Model
class. And don\'t forget find
! By changing its underlying implementation, we essentially optimize the performance of all queries that rely on the id
attribute.
export default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].get(id);\\n }\\n}\\n
This is a simple optimization that can greatly improve the performance of your application, especially if you\'re dealing with a large number of records.
\\nIn reality, in my project I didn\'t implement this exactly like that. The main difference is that I made the id
attribute a configurable set of attributes that each model can define for itself. This makes the code a little more complicated and doesn\'t depart a ton of value at this point, but you can easily figure it out for yourself.
In this second installment of our journey to implement an ActiveRecord-like pattern in JavaScript, we\'ve focused on modeling complex object attributes and relationships. While it may have sounded a little intimidating at first, it wasn\'t so bad, right? The power of JavaScript\'s object-oriented features and built-in methods can help you abstract complexity fairly easily.
\\nThere\'s a ton more to explore in this project, so stay tuned for the next installment where we\'ll dive into yet more advanced behavior, implementation details and optimizations. Until then, happy coding!
\\nYet again, here\'s the complete implementation up until this point in the series. You may want to use it as a reference point or to test the code yourself.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].get(id);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n this.authorId = data.authorId;\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.name = data.name;\\n this.surname = data.surname;\\n this.email = data.email;\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
Last updated: December 12, 2024 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This is the second article in a series about a recent project I implemented, inspired by Ruby on Rails\' ActiveRecord pattern. It\'s more of an advanced show & tell, aiming to inspire readers to use more advanced JavaScript features and patterns. If you haven\'t read…","guid":"https://www.30secondsofcode.org/js/s/complex-object-attributes-relationships","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-12-11T16:00:00.437Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/digital-nomad-15-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/digital-nomad-15-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object collections in memory","url":"https://www.30secondsofcode.org/js/s/complex-object-collections-in-memory","content":"I\'ve been working with Ruby on Rails quite a lot lately. What I\'ve come to like about it the most may be its ActiveRecord ORM (Object-Relational Mapping). I won\'t go into detail about here, but I wanted to share a similar sort of concept I put together for this very website using JavaScript.
\\nThis is more of a show & tell, rather than a tutorial. It\'s fairly complex and quite advanced, so I won\'t be explaining every single detail. The hope is to inspire you to think about using more advanced JavaScript features and building some interesting things with them.
\\nWhat I wanted to accomplish with this project is to have a way to load a fairly large amount of plain objects, which are related to each other. Instead of JavaScript\'s nesting, referencing and moving around things, I wanted something simple and elegant.
\\nActiveRecord was my initial inspiration, but the implementation has delved a little bit away from it. Instead of relying on a database, my need was to do this on the fly, using memory. I also didn\'t have a need to update the data in the objects. What I was essentially after was a way to load a snapshot of data into memory, populate a bunch of models and then query them.
\\nBefore we move any further, I think it\'s wise to set up the directory structure, as well as some conventions for how files are loaded. I\'m using ESM (ECMAScript Modules) for this project, so I can use import
and export
statements. ESM also comes with the ability to define an imports
field in package.json
, which allows me to define aliases for my imports.
The overall directory structure looks like this:
\\nsrc/\\n├── core/\\n│ ├── model.js\\n│ └── recordSet.js\\n└── models/\\n └── post.js\\n
We\'ll come to expand upon this setup in future posts, but for now, this is all we need. The core
directory contains the core classes, while the models
directory contains the model classes.
I\'ve also set up the imports
field in package.json
to alias the src
directory. This way, files can be imported using the #src
alias, instead of having to use relative paths.
// package.json\\n{\\n \\"imports\\": {\\n \\"#src/*\\": [\\n \\"./src/*\\"\\n ]\\n }\\n}\\n
At the core of this project are two main classes: Model
, RecordSet
. In future articles, we\'ll explore some more of the features that can be added to these classes, but for now, let\'s focus on the basics.
The Model
is the base class for all models. Its job is to provide a way to define attributes and relationships. Model instances (or records) are, for the intents of this implementation, considered immutable. The RecordSet
is a collection of records, which can be queried and filtered.
In this article, I use the terms instance and record interchangeably. They both refer to a single model instance in memory. Do not confuse the term \\"record\\" with a database record, the JavaScript Record
type or the class of the same name implemented by various libraries.
As we\'ve already mentioned, all model records are immutable and loaded into memory. This means that each model\'s records are known ahead of time, so each model class can have a static
property to store all of its instances.
Unfortunately, if we are to create a core Model
class and define a static
property on it, it would be shared across all subclasses. This isn\'t what we want.
After rummaging through various websites, I came across static initialization blocks, a relatively new addition to JavaScript. It allows us to run some code when the class is defined, but before any instances are created.
\\nYou may be thinking but how does this help us? If we had a global storage for all model instances, we could use a static initialization block to create an array for each subclass. This way, each model class would have its own array of instances.
\\nBut how and where will we create such a storage? Well, in the Model
class, obviously! And, while we\'re at it, we can create a static method to prepare the storage for each model class. We\'ll later explore what other useful things we can bundle into this method in the future.
export default class Model {\\n static instances = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n }\\n}\\n
Having defined the Model
class and its prepare
method, we can now create a subclass of Model
and call the prepare
method on it inside a static initialization block. This will create an array for the subclass to store its instances.
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n}\\n
As you can see, we pass the model class itself to the prepare
method. This way, we can access the class name and create an array for it in the instances
object. After the new model is defined, the Model
class\'s instances
property will look like this:
Model.instaces; // { Post: [] }\\n
Querying records is the most crucial part of this implementation. After all, it\'s the main reason we\'re doing this. We can create a RecordSet
as a subclass of Array
class to handle this responsibility. It provides a way to query records based on their attributes.
While the name may suggest that the RecordSet
is a subclass of Set
, I\'ve found that arrays are much simpler to work with. Thus, I decided to subclass Array
instead, but the original name stuck, reminding me of its Set
origins.
Leveraging the power of array methods, we need only implement a handful of alias methods to make querying records a breeze. Fan favorites such as Array.prototype.filter()
, Array.prototype.find()
, Array.prototype.map()
, Array.prototype.reduce()
, and Array.prototype.sort()
are already available to us!
where
methodThe single most useful piece of functionality we need to implement is the where
method. This method will allow us to query records based on their attributes. Again, drawing inspiration from ActiveRecord, I chose to give it a great degree of flexibility, which is reflected in its arguments.
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n}\\n
This implementation allows us to query records based on their attributes. It expects a query object, where each key is an attribute name and each value is the expected value. The expectation can then vary quite a lot, namely:
\\n===
).We\'ll come back to use this method in just a moment, but first we need to populate the model instances!
\\nWe\'ve defined the basics of the core classes, but we also need to load some data into memory. The Model
is the best place to do this, via the use of its constructor
. This way, we can load the data into instances
when a new model instance is created.
export default class Model {\\n // ...\\n constructor() {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances array\\n Model.instances[modelName].push(this);\\n }\\n}\\n
Now, when we create a new instance of a model, it will be stored in the instances
array. Each class can simply call the super
constructor, then load its data into the instance.
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n constructor(data) {\\n super();\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n }\\n}\\n
Having set up the two core classes and loaded some data into memory, we should finally be able to query the records. Except, we don\'t have any way to query them yet.
\\nThis is yet another responsibility for the Model
class. Leveraging the resolution of this
in the context of static
methods, we can essentially create query methods on all subclasses, referencing themselves.
Ok, that last bit was somewhat confusing. Let me explain. If we define a static
method on Model
and it references this
, it will resolve to the subclass that called the method. Neat, right? This way, we can find the right set of instances in the instances
array.
Confused? Don\'t worry, the this
keyword is a tricky thing to master. I suggest reading this article to get a better understanding of it.
Given that a RecordSet
is just an array on steroids, we can create a new RecordSet
from it, using Array.from()
, or rather, RecordSet.from()
. Putting the pieces together, we can then define a static getter method, all
, on the Model
class, which will return a RecordSet
of all its instances.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n}\\n
Why a getter method instead of a regular one? Well, it\'s a matter of preference. I like how parenthesis are optional in Ruby, so I wanted to mimic that behavior. This way, I can call Post.all
instead of Post.all()
. Feel free to tailor this to your own taste!
So, what can we do with this? Filter, sort, use where
and other methods on the RecordSet
! Let\'s see how we can use the where
method to query the records.
const posts = [\\n new Post({ id: 1, title: \'A post\', content: \'...\' }),\\n new Post({ id: 2, title: \'Other post\', content: \'...\' }),\\n new Post({ id: 3, title: \'A draft\', content: \'...\' }),\\n];\\n\\nPost.all.where({ title: \'First post\' });\\n// [Post { id: 1, title: \'First post\', content: \'...\' }]\\n\\nPost.all.where({ title: title => title.startsWith(\'A\') });\\n// [\\n// Post { id: 1, title: \'A post\', content: \'...\' },\\n// Post { id: 3, title: \'A draft\', content: \'...\' }\\n// ]\\n\\nPost.all.where({ id: [2, 3] });\\n// [\\n// Post { id: 1, title: \'A post\', content: \'...\' },\\n// Post { id: 2, title: \'Other post\', content: \'...\' }\\n// ]\\n\\nPost.all.where({ id: id => id % 2 === 1, title: \'A post\' });\\n// [Post { id: 1, title: \'A post\', content: \'...\' }]\\n
Querying is relatively simple using where
, but we can also use other array methods. For example, we can map over the records or even reduce them!
Post.all.map(post => post.title);\\n// [\'A post\', \'Other post\', \'A draft\']\\n\\nPost.all.reduce((acc, post) =>\\n post.title.includes(\'post\') ? acc + 1 : acc, 0);\\n// 2\\n
Before we wrap up, let\'s add some finishing touches, at least for the time being.
\\nFirst off, I want to make where
available to the models themselves. Calling all
seems a little to verbose, after all.
export default class Model {\\n // ...\\n static where(query) {\\n return this.all.where(query);\\n }\\n}\\n
See how we\'re using this
in the where
method again? Which will in turn call all
with the same this
context, ultimately resolving to the calling subclass. Eloquently simple, isn\'t it?
Apart from ActiveRecord, Ruby and Rails provide an absolute treasure trove of convenience methods. I especially like first
and last
on enumerable objects, making indexing in other languages seem so cumbersome. Let\'s add these to our RecordSet
as well.
export default class RecordSet extends Array {\\n // ...\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
Finally, I\'d like a way to quickly pull some attributes from the records or create a set of partial records. This is where we can leverage the array-like behavior of the RecordSet
and combine it with the power of Array.prototype.map()
.
A pluck
method will allow us to pull a single attribute from each record, while a select
method will allow us to pull multiple attributes from each record.
export default class RecordSet extends Array {\\n // ...\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n}\\n
If you\'re asking how are these different from Array.prototype.map()
, they\'re merely a convenience wrapper around it. We intentionally return a RecordSet
from these methods, so we can chain them with other RecordSet
methods. Remember RecordSet
instances are also arrays, so they can be used as such, too, which makes them very flexible.
One finer detail here is that we use super
to call the Array.prototype.map()
method. This is because we\'re extending Array
and we want to call the original method, in case we ever decide to override it in our class (spoiler: I\'ve done this in my original implementation).
I hope you\'ve enjoyed this deep dive into this particular implementation. While it may seem esoteric at first, it\'s a great opportunity to learn about more complex topics and modern JavaScript features. I\'ve found the implementation to be quite capable, and I\'ve already put it to good use. I\'m sure you can find some use for it, too!
\\nWow, that was a lot! And here I was thinking I\'d explain the rest of the codebase in this article. I guess I\'ll have to save that for another time, so stay tuned for that!
\\nIf you\'re looking for the complete implementation, you can find it below. More code will be added in subsequent articles, so it may come in handy as a reference point.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances array\\n Model.instances[modelName].push(this);\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super();\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n }\\n}\\n
Last updated: December 5, 2024 View on GitHub ·\\nJoin the discussion
","description":"I\'ve been working with Ruby on Rails quite a lot lately. What I\'ve come to like about it the most may be its ActiveRecord ORM (Object-Relational Mapping). I won\'t go into detail about here, but I wanted to share a similar sort of concept I put together for this very website…","guid":"https://www.30secondsofcode.org/js/s/complex-object-collections-in-memory","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-12-04T16:00:00.347Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/digital-nomad-4-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/digital-nomad-4-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Display the right type of keyboard for form inputs","url":"https://www.30secondsofcode.org/html/s/keyboard-type-using-inputmode","content":"A common issue with form inputs on mobile devices is that the wrong keyboard is displayed, making it harder for users to fill out the form. For example, a numeric keyboard should be shown for a phone number input, but a regular keyboard is displayed instead. This can be frustrating for users and can lead to incorrect input.
\\nLuckily, HTML has a built-in solution for this, via the inputmode
attribute. This attribute allows you to specify the type of keyboard that should be displayed for an input field. Here\'s how you can use it:
<label>\\n Phone number: <input type=\\"tel\\" inputmode=\\"tel\\" />\\n</label>\\n
In this example, the inputmode=\\"tel\\"
attribute tells the browser to display a telephone keypad for the input field, making it easier for users to enter a phone number. Here\'s all the possible values for the inputmode
attribute:
none
: No virtual keyboard is shown.text
: A regular keyboard is shown.url
: A URL keyboard is shown (includes keys like .com and /).email
: An email keyboard is shown (includes keys like @ and .).tel
: A telephone keypad is shown.search
: A search keyboard is shown (includes a Go button).numeric
: A numeric keyboard is shown.decimal
: A numeric keyboard with a decimal point is shown.The inputmode
attribute is not a replacement for the type
attribute. The former acts as a hint to the browser about the type of keyboard to display, while the latter specifies the type of data expected in the input field.
Last updated: December 3, 2024 View on GitHub
","description":"A common issue with form inputs on mobile devices is that the wrong keyboard is displayed, making it harder for users to fill out the form. For example, a numeric keyboard should be shown for a phone number input, but a regular keyboard is displayed instead. This can be…","guid":"https://www.30secondsofcode.org/html/s/keyboard-type-using-inputmode","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-12-02T16:00:00.745Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/yellow-shoes-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/yellow-shoes-800.webp","type":"photo","width":360,"height":180}],"categories":[" HTML "],"attachments":null,"extra":null,"language":null},{"title":"Styling checkboxes and radio buttons","url":"https://www.30secondsofcode.org/css/s/custom-checkbox-radio","content":"One of the most common pain points in web development is styling checkboxes and radio buttons. They are notoriously difficult to style consistently across browsers and platforms. However, with a little CSS magic, you can create custom checkboxes and radio buttons that look great and are easy to use.
\\n\\n See the embedded CodePen\\n
\\n \\nFor a custom checkbox, the best solution I\'ve found is to use an <svg>
element to create the checkmark symbol and insert it via the <use>
element to create a reusable SVG icon.
You can then create a container and use flexbox to create the appropriate layout for the checkboxes. Hide the <input>
element and use the <label>
associated with it to display a checkbox and the provided text.
Then, using stroke-dashoffset
, you can animate the check symbol on state change. Finally, use transform: scale(0.9)
via a CSS animation to create a zoom animation effect.
<svg class=\\"checkbox-symbol\\">\\n <symbol id=\\"check\\" viewbox=\\"0 0 12 10\\">\\n <polyline\\n points=\\"1.5 6 4.5 9 10.5 1\\"\\n stroke-linecap=\\"round\\"\\n stroke-linejoin=\\"round\\"\\n stroke-width=\\"2\\"\\n ></polyline>\\n </symbol>\\n</svg>\\n\\n<div class=\\"checkbox-container\\">\\n <input class=\\"checkbox-input\\" id=\\"apples\\" type=\\"checkbox\\" />\\n <label class=\\"checkbox\\" for=\\"apples\\">\\n <span>\\n <svg width=\\"12px\\" height=\\"10px\\">\\n <use xlink:href=\\"#check\\"></use>\\n </svg>\\n </span>\\n <span>Apples</span>\\n </label>\\n <input class=\\"checkbox-input\\" id=\\"oranges\\" type=\\"checkbox\\" />\\n <label class=\\"checkbox\\" for=\\"oranges\\">\\n <span>\\n <svg width=\\"12px\\" height=\\"10px\\">\\n <use xlink:href=\\"#check\\"></use>\\n </svg>\\n </span>\\n <span>Oranges</span>\\n </label>\\n</div>
\\n.checkbox-symbol {\\n position: absolute;\\n width: 0;\\n height: 0;\\n pointer-events: none;\\n user-select: none;\\n}\\n\\n.checkbox-container {\\n box-sizing: border-box;\\n background: #ffffff;\\n color: #222;\\n height: 64px;\\n display: flex;\\n justify-content: center;\\n align-items: center;\\n flex-flow: row wrap;\\n}\\n\\n.checkbox-container * {\\n box-sizing: border-box;\\n}\\n\\n.checkbox-input {\\n position: absolute;\\n visibility: hidden;\\n}\\n\\n.checkbox {\\n user-select: none;\\n cursor: pointer;\\n padding: 6px 8px;\\n border-radius: 6px;\\n overflow: hidden;\\n transition: all 0.3s ease;\\n display: flex;\\n}\\n\\n.checkbox:not(:last-child) {\\n margin-right: 6px;\\n}\\n\\n.checkbox:hover {\\n background: rgba(0, 119, 255, 0.06);\\n}\\n\\n.checkbox span {\\n vertical-align: middle;\\n transform: translate3d(0, 0, 0);\\n}\\n\\n.checkbox span:first-child {\\n position: relative;\\n flex: 0 0 18px;\\n width: 18px;\\n height: 18px;\\n border-radius: 4px;\\n transform: scale(1);\\n border: 1px solid #cccfdb;\\n transition: all 0.3s ease;\\n}\\n\\n.checkbox span:first-child svg {\\n position: absolute;\\n top: 3px;\\n left: 2px;\\n fill: none;\\n stroke: #fff;\\n stroke-dasharray: 16px;\\n stroke-dashoffset: 16px;\\n transition: all 0.3s ease;\\n transform: translate3d(0, 0, 0);\\n}\\n\\n.checkbox span:last-child {\\n padding-left: 8px;\\n line-height: 18px;\\n}\\n\\n.checkbox:hover span:first-child {\\n border-color: #0077ff;\\n}\\n\\n.checkbox-input:checked + .checkbox span:first-child {\\n background: #0077ff;\\n border-color: #0077ff;\\n animation: zoom-in-out 0.3s ease;\\n}\\n\\n.checkbox-input:checked + .checkbox span:first-child svg {\\n stroke-dashoffset: 0;\\n}\\n\\n@keyframes zoom-in-out {\\n 50% {\\n transform: scale(0.9);\\n }\\n}
\\nFor a custom radio button, you can use pseudo-elements to do the heavy lifting. Same as before, create a container and use flexbox to create the appropriate layout for the radio buttons. Reset the styles on the <input>
element and use it to create the outline and background of the radio button.
Then, use the ::before
pseudo-element to create the inner circle of the radio button. Use transform: scale(1)
and a CSS transition to create an animation effect on state change.
<div class=\\"radio-container\\">\\n <input class=\\"radio-input\\" id=\\"apples\\" type=\\"radio\\" name=\\"fruit\\" />\\n <label class=\\"radio\\" for=\\"apples\\">Apples</label>\\n <input class=\\"radio-input\\" id=\\"oranges\\" type=\\"radio\\" name=\\"fruit\\" />\\n <label class=\\"radio\\" for=\\"oranges\\">Oranges</label>\\n</div>
\\n.radio-container {\\n box-sizing: border-box;\\n background: #ffffff;\\n color: #222;\\n height: 64px;\\n display: flex;\\n justify-content: center;\\n align-items: center;\\n flex-flow: row wrap;\\n}\\n\\n.radio-container * {\\n box-sizing: border-box;\\n}\\n\\n.radio-input {\\n appearance: none;\\n background-color: #ffffff;\\n width: 16px;\\n height: 16px;\\n border: 1px solid #cccfdb;\\n margin: 0;\\n border-radius: 50%;\\n display: grid;\\n align-items: center;\\n justify-content: center;\\n transition: all 0.3s ease;\\n}\\n\\n.radio-input::before {\\n content: \\"\\";\\n width: 6px;\\n height: 6px;\\n border-radius: 50%;\\n transform: scale(0);\\n transition: 0.3s transform ease-in-out;\\n box-shadow: inset 6px 6px #ffffff;\\n}\\n\\n.radio-input:checked {\\n background: #0077ff;\\n border-color: #0077ff;\\n}\\n\\n.radio-input:checked::before {\\n transform: scale(1);\\n}\\n\\n.radio {\\n cursor: pointer;\\n padding: 6px 8px;\\n}\\n\\n.radio:not(:last-child) {\\n margin-right: 6px;\\n}
\\nLast updated: September 14, 2024 View On GitHub
","description":"One of the most common pain points in web development is styling checkboxes and radio buttons. They are notoriously difficult to style consistently across browsers and platforms. However, with a little CSS magic, you can create custom checkboxes and radio buttons that look great…","guid":"https://www.30secondsofcode.org/css/s/custom-checkbox-radio","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-09-13T16:00:00.516Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/interior-8-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/interior-8-800.webp","type":"photo","width":360,"height":180}],"categories":[" CSS "," Visual "],"attachments":null,"extra":null,"language":null},{"title":"Alternating text animations","url":"https://www.30secondsofcode.org/css/s/alternating-text","content":"Alternating text animations are a great way to add some playfulness to your website. They can be used to display different words or phrases in a loop, often used to showcase a unique selling point or a list of features.
\\nLuckily, with a little CSS and JavaScript, you can create your own. Starting with the CSS, you need an element to display the content and a simple animation
to make the text disappear.
Then, in JavaScript, you can define an array of the different words or phrases you want to alternate between and use the first one to initialize the content. By using EventTarget.addEventListener()
to listen for the \'animationiteration\'
event, you can update the content of the element to the next word in the array each time the animation completes an iteration.
.alternating {\\n animation-name: alternating-text;\\n animation-duration: 3s;\\n animation-iteration-count: infinite;\\n animation-timing-function: ease;\\n}\\n\\n@keyframes alternating-text {\\n 90% {\\n display: none;\\n }\\n}\\n
const texts = [\'Java\', \'Python\', \'C\', \'C++\', \'C#\', \'Javascript\'];\\nconst element = document.querySelector(\'.alternating\');\\n\\nlet i = 0;\\nconst listener = e => {\\n i = i < texts.length - 1 ? i + 1 : 0;\\n element.innerHTML = texts[i];\\n};\\n\\nelement.innerHTML = texts[0];\\nelement.addEventListener(\'animationiteration\', listener, false);\\n
\\n See the embedded CodePen\\n
\\n \\nThis implementation is not accessible to screen readers. If you want to make it accessible, consider using ARIA attributes or other techniques to ensure that the content is still readable by all users.
\\nLast updated: September 12, 2024 View on GitHub
","description":"Alternating text animations are a great way to add some playfulness to your website. They can be used to display different words or phrases in a loop, often used to showcase a unique selling point or a list of features.\\n\\nLuckily, with a little CSS and JavaScript, you can create…","guid":"https://www.30secondsofcode.org/css/s/alternating-text","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-09-11T16:00:00.245Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/italian-horizon-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/italian-horizon-800.webp","type":"photo","width":360,"height":180}],"categories":[" CSS "," Animation "],"attachments":null,"extra":null,"language":null},{"title":"Card hover effects","url":"https://www.30secondsofcode.org/css/s/card-hover-effects","content":"Cards, one of the most common layout elements in modern web design, provide a ton of opportunities for creative hover effects. Here are a few examples of card hover effects that you can use to make your website more interactive and engaging.
\\n\\n See the embedded CodePen\\n
\\n \\nTo create a two-sided card that rotates on hover, you first need a container element with two child elements, one for the front side and one for the back side of the card. You can then use the rotateY()
function to rotate the card around the Y-axis, and the backface-visibility
property to hide the back side of the card when it is not visible.
<div class=\\"rotating-card\\">\\n <div class=\\"card-side front\\"></div>\\n <div class=\\"card-side back\\"></div>\\n</div>\\n
.rotating-card {\\n perspective: 150rem;\\n position: relative;\\n box-shadow: none;\\n background: none;\\n}\\n\\n.rotating-card .card-side {\\n transition: all 0.8s ease;\\n backface-visibility: hidden;\\n position: absolute;\\n top: 0;\\n left: 0;\\n width: 100%;\\n height: 100%;\\n}\\n\\n.rotating-card .card-side.back {\\n transform: rotateY(-180deg);\\n}\\n\\n.rotating-card:hover .card-side.front {\\n transform: rotateY(180deg);\\n}\\n\\n.rotating-card:hover .card-side.back {\\n transform: rotateY(0deg);\\n}\\n
For a shifting card, you will need to leverage CSS variables and a little bit of JavaScript to track the position of the mouse cursor and adjust the card\'s position accordingly. You\'ll use the mousemove
event to track the cursor\'s position and calculate the relative distance between the cursor and the center of the card, using Element.getBoundingClientRect()
to get the card\'s position and dimensions.
Then, using the CSS variables, you can apply a transform
property to the card element that shifts it based on the cursor\'s position. To make the change smoother, use the transition
property to animate the transformation.
.shifting-card {\\n transition: transform 0.2s ease-out;\\n transform: rotateX(calc(10deg * var(--dx, 0)))\\n rotateY(calc(10deg * var(--dy, 0)));\\n}\\n
const card = document.querySelector(\'.shifting-card\');\\nconst { x, y, width, height } = card.getBoundingClientRect();\\nconst cx = x + width / 2;\\nconst cy = y + height / 2;\\n\\nconst handleMove = e => {\\n const { pageX, pageY } = e;\\n const dx = (cx - pageX) / (width / 2);\\n const dy = (cy - pageY) / (height / 2);\\n e.target.style.setProperty(\'--dx\', dx);\\n e.target.style.setProperty(\'--dy\', dy);\\n};\\n\\ncard.addEventListener(\'mousemove\', handleMove);\\n
Finally, for the perspective card, you will only need a transform
with a perspective()
function and a rotateY()
function to create the perspective effect. The transition
property will animate the transform
attribute on hover.
.perspective-card {\\n transform: perspective(1500px) rotateY(15deg);\\n transition: transform 1s ease 0s;\\n}\\n\\n.perspective-card:hover {\\n transform: perspective(3000px) rotateY(5deg);\\n}
Last updated: September 11, 2024 View on GitHub
","description":"Cards, one of the most common layout elements in modern web design, provide a ton of opportunities for creative hover effects. Here are a few examples of card hover effects that you can use to make your website more interactive and engaging.\\n\\nSee the embedded…","guid":"https://www.30secondsofcode.org/css/s/card-hover-effects","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-09-10T16:00:00.929Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/clouds-n-mountains-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/clouds-n-mountains-800.webp","type":"photo","width":360,"height":180}],"categories":[" CSS "," Animation "],"attachments":null,"extra":null,"language":null},{"title":"Image gallery with horizontal or vertical scroll","url":"https://www.30secondsofcode.org/css/s/horizontal-vertical-gallery","content":"Image galleries are useful in various contexts, from showcasing products to displaying a collection of images. Depending on your needs, you may want to create a horizontally or vertically scrollable gallery. While CSS has come a long way, unfortunately, you\'ll have to get your hands dirty with JavaScript to achieve this effect.
\\nYou may first want to get caught up with scroll snapping to understand how it works, if you haven\'t already.
\\nTo create a vertically scrollable image gallery, you will need to create a container with display: flex
and justify-content: center
and a set of slides with display: flex
and flex-direction: column
.
You will also need to use scroll-snap-type: y mandatory
and overscroll-behavior-y: contain
to create a snap effect on vertical scroll. Snap elements to the start of the container using scroll-snap-align: start
. In order to hide the scrollbars, you can use scrollbar-width: none
and style the pseudo-element ::-webkit-scrollbar
to display: none
.
Then, you can use Element.scrollTo()
to define a scrollToElement
function that scrolls the gallery to the given item. You can use Array.prototype.map()
and Array.prototype.join()
to populate the .thumbnails
element. Give each thumbnail a data-id
attribute with the index of the image.
Using the Document.querySelectorAll()
method, you can get all the thumbnail elements. Use Array.prototype.forEach()
to register a handler for the \'click\'
event on each thumbnail, using EventTarget.addEventListener()
and the scrollToElement
function.
Finally, use Document.querySelector()
and EventTarget.addEventListener()
to register a handler for the \'scroll\'
event. Update the .thumbnails
and .scrollbar
elements to match the current scroll position, using the scrollThumb
function.
\\n See the embedded CodePen\\n
\\n \\n<div class=\\"gallery-container\\">\\n <div class=\\"thumbnails\\"></div>\\n <div class=\\"scrollbar\\">\\n <div class=\\"thumb\\"></div>\\n </div>\\n <div class=\\"slides\\">\\n <div><img src=\\"https://picsum.photos/id/1067/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/122/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/188/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/249/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/257/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/259/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/283/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/288/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/299/540/720\\"></div>\\n </div>\\n</div>\\n
.gallery-container {\\n display: flex;\\n justify-content: center;\\n}\\n\\n.thumbnails {\\n display: flex;\\n flex-direction: column;\\n gap: 8px;\\n}\\n\\n.thumbnails img {\\n width: 40px;\\n height: 40px;\\n cursor: pointer;\\n}\\n\\n.scrollbar {\\n width: 1px;\\n height: 720px;\\n background: #ccc;\\n display: block;\\n margin: 0 0 0 8px;\\n}\\n\\n.thumb {\\n width: 1px;\\n position: absolute;\\n height: 0;\\n background: #000;\\n}\\n\\n.slides {\\n margin: 0 16px;\\n display: grid;\\n grid-auto-flow: row;\\n gap: 1rem;\\n width: calc(540px + 1rem);\\n padding: 0 0.25rem;\\n height: 720px;\\n overflow-y: auto;\\n overscroll-behavior-y: contain;\\n scroll-snap-type: y mandatory;\\n scrollbar-width: none;\\n}\\n\\n.slides > div {\\n scroll-snap-align: start;\\n}\\n\\n.slides img {\\n width: 540px;\\n object-fit: contain;\\n}\\n\\n.slides::-webkit-scrollbar {\\n display: none;\\n}\\n
const slideGallery = document.querySelector(\'.slides\');\\nconst slides = slideGallery.querySelectorAll(\'div\');\\nconst scrollbarThumb = document.querySelector(\'.thumb\');\\nconst slideCount = slides.length;\\nconst slideHeight = 720;\\nconst marginTop = 16;\\n\\nconst scrollThumb = () => {\\n const index = Math.floor(slideGallery.scrollTop / slideHeight);\\n scrollbarThumb.style.height = `${((index + 1) / slideCount) * slideHeight}px`;\\n};\\n\\nconst scrollToElement = el => {\\n const index = parseInt(el.dataset.id, 10);\\n slideGallery.scrollTo(0, index * slideHeight + marginTop);\\n};\\n\\ndocument.querySelector(\'.thumbnails\').innerHTML += [...slides]\\n .map(\\n (slide, i) => `<img src=\\"${slide.querySelector(\'img\').src}\\" data-id=\\"${i}\\">`\\n )\\n .join(\'\');\\n\\ndocument.querySelectorAll(\'.thumbnails img\').forEach(el => {\\n el.addEventListener(\'click\', () => scrollToElement(el));\\n});\\n\\nslideGallery.addEventListener(\'scroll\', e => scrollThumb());\\n\\nscrollThumb();\\n
To create a horizontally scrollable image gallery, you will need to position its .thumbnails
container at the bottom of the gallery, using position: absolute
. Then, use scroll-snap-type: x mandatory
and overscroll-behavior-x: contain
to create a snap effect on horizontal scroll. Snap elements to the start of the container using scroll-snap-align: start
.
Hide the scrollbars the same way as before. Use Element.scrollTo()
to define a scrollToElement
function that scrolls the gallery to the given item. Populate the .thumbnails
element using Array.prototype.map()
and Array.prototype.join()
, giving each thumbnail a data-id
attribute with the index of the image.
Use Document.querySelectorAll()
to get all the thumbnail elements and register \'click\'
event handlers on each thumbnail, using the highlightThumbnail
function. Finally, register a handler for the \'scroll\'
event the same as before and update the .thumbnails
element to match the current scroll position using the highlightThumbnail
function.
\\n See the embedded CodePen\\n
\\n \\n<div class=\\"gallery-container\\">\\n <div class=\\"thumbnails\\"></div>\\n <div class=\\"slides\\">\\n <div><img src=\\"https://picsum.photos/id/1067/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/122/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/188/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/249/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/257/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/259/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/283/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/288/540/720\\"></div>\\n <div><img src=\\"https://picsum.photos/id/299/540/720\\"></div>\\n </div>\\n</div>\\n
.gallery-container {\\n position: relative;\\n display: flex;\\n justify-content: center;\\n}\\n\\n.thumbnails {\\n position: absolute;\\n bottom: 8px;\\n display: flex;\\n flex-direction: row;\\n gap: 6px;\\n}\\n\\n.thumbnails div {\\n width: 8px;\\n height: 8px;\\n cursor: pointer;\\n background: #aaa;\\n border-radius: 100%;\\n}\\n\\n.thumbnails div.highlighted {\\n background-color: #777;\\n}\\n\\n.slides {\\n margin: 0 16px;\\n display: grid;\\n grid-auto-flow: column;\\n gap: 1rem;\\n width: 540px;\\n padding: 0 0.25rem;\\n height: 720px;\\n overflow-y: auto;\\n overscroll-behavior-x: contain;\\n scroll-snap-type: x mandatory;\\n scrollbar-width: none;\\n}\\n\\n.slides > div {\\n scroll-snap-align: start;\\n}\\n\\n.slides img {\\n width: 540px;\\n object-fit: contain;\\n}\\n\\n.slides::-webkit-scrollbar {\\n display: none;\\n}\\n
const slideGallery = document.querySelector(\'.slides\');\\nconst slides = slideGallery.querySelectorAll(\'div\');\\nconst thumbnailContainer = document.querySelector(\'.thumbnails\');\\nconst slideCount = slides.length;\\nconst slideWidth = 540;\\n\\nconst highlightThumbnail = () => {\\n thumbnailContainer\\n .querySelectorAll(\'div.highlighted\')\\n .forEach(el => el.classList.remove(\'highlighted\'));\\n const index = Math.floor(slideGallery.scrollLeft / slideWidth);\\n thumbnailContainer\\n .querySelector(`div[data-id=\\"${index}\\"]`)\\n .classList.add(\'highlighted\');\\n};\\n\\nconst scrollToElement = el => {\\n const index = parseInt(el.dataset.id, 10);\\n slideGallery.scrollTo(index * slideWidth, 0);\\n};\\n\\nthumbnailContainer.innerHTML += [...slides]\\n .map((slide, i) => `<div data-id=\\"${i}\\"></div>`)\\n .join(\'\');\\n\\nthumbnailContainer.querySelectorAll(\'div\').forEach(el => {\\n el.addEventListener(\'click\', () => scrollToElement(el));\\n});\\n\\nslideGallery.addEventListener(\'scroll\', e => highlightThumbnail());\\n\\nhighlightThumbnail();
Last updated: September 5, 2024 View on GitHub
","description":"Image galleries are useful in various contexts, from showcasing products to displaying a collection of images. Depending on your needs, you may want to create a horizontally or vertically scrollable gallery. While CSS has come a long way, unfortunately, you\'ll have to get your…","guid":"https://www.30secondsofcode.org/css/s/horizontal-vertical-gallery","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-09-04T16:00:00.861Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/flower-portrait-5-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/flower-portrait-5-800.webp","type":"photo","width":360,"height":180}],"categories":[" CSS "," Visual "],"attachments":null,"extra":null,"language":null},{"title":"How can I get all ancestors, parents, siblings, and children of an element?","url":"https://www.30secondsofcode.org/js/s/get-ancestors-parents-siblings-children","content":"DOM traversal is a very useful skill to have if you\'re working with JavaScript and the browser. It allows you to navigate the DOM tree and find elements that are related to a given element. Let\'s explore how to use it to our advantage.
\\nAll the examples in this article make use of the Node
interface, which is the base class for all nodes in the DOM, including elements, text nodes, and comments. Additionally, the functions return arrays of elements, not NodeList
objects, to make them easier to work with.
Oddly enough, there are two ways to get an element\'s children: Node.childNodes
and Node.children
. The difference between the two is that Node.childNodes
returns all child nodes, including text nodes, while Node.children
returns only element nodes.
Depending on our needs, we can use either of these properties to get an element\'s children, so let\'s use an argument to decide which one to return.
\\nconst getChildren = (el, includeTextNodes = false) =>\\n includeTextNodes ? [...el.childNodes] : [...el.children];\\n\\ngetChildren(document.querySelector(\'ul\'));\\n// [li, li, li]\\n\\ngetChildren(document.querySelector(\'ul\'), true);\\n// [li, #text, li, #text, li, #text]\\n
To get an element\'s siblings, we can use the Node.parentNode
property to access the parent node and then use Node.childNodes
to get all the children of the parent.
We can then convert the NodeList
to an array using the spread operator (...
). Finally, we can filter out the element itself from the list of children to get the siblings using Array.prototype.filter()
.
const getSiblings = el =>\\n [...el.parentNode.childNodes].filter(node => node !== el);\\n\\ngetSiblings(document.querySelector(\'head\'));\\n// [\'body\']\\n
To get all the ancestors of an element, we can use a while
loop and the Node.parentNode
property to move up the ancestor tree of the element. We can then use Array.prototype.unshift()
to add each new ancestor to the start of the array.
const getAncestors = el => {\\n let ancestors = [];\\n\\n while (el) {\\n ancestors.unshift(el);\\n el = el.parentNode;\\n }\\n\\n return ancestors;\\n};\\n\\ngetAncestors(document.querySelector(\'nav\'));\\n// [document, html, body, header, nav]\\n
Building on top of the previous examples, we can create functions to match related nodes based on a given condition. For example, we can find all the ancestors of an element up until the element matched by a specified selector, or find the closest anchor element to a given node.
\\nTo check if an element contains another element, we can simply use the Node.contains()
method.
const elementContains = (parent, child) =>\\n parent !== child && parent.contains(child);\\n\\nelementContains(\\n document.querySelector(\'head\'),\\n document.querySelector(\'title\')\\n);\\n// true\\n\\nelementContains(\\n document.querySelector(\'body\'),\\n document.querySelector(\'body\')\\n);\\n// false\\n
Finding the closest matching node starting at the given node
is often useful for event handling. We can use a for
loop and Node.parentNode
to traverse the node tree upwards from the given node
. We then use Element.matches()
to check if any given element node matches the provided selector
.
const findClosestMatchingNode = (node, selector) => {\\n for (let n = node; n.parentNode; n = n.parentNode)\\n if (n.matches && n.matches(selector)) return n;\\n\\n return null;\\n};\\n\\nfindClosestMatchingNode(\\n document.querySelector(\'a\'), \'body\'\\n);\\n// body\\n
To find all the ancestors of an element up until the element matched by the specified selector, we can use a while
loop and Node.parentNode
to move up the ancestor tree of the element. We can then use Array.prototype.unshift()
to add each new ancestor to the start of the array and Element.matches()
to check if the current element matches the specified selector
.
const getParentsUntil = (el, selector) => {\\n let parents = [], _el = el.parentNode;\\n\\n while (_el && typeof _el.matches === \'function\') {\\n parents.unshift(_el);\\n\\n if (_el.matches(selector)) return parents;\\n else _el = _el.parentNode;\\n }\\n\\n return [];\\n};\\n\\ngetParentsUntil(document.querySelector(\'#home-link\'), \'header\');\\n// [header, nav, ul, li]
Last updated: August 21, 2024 View on GitHub
","description":"DOM traversal is a very useful skill to have if you\'re working with JavaScript and the browser. It allows you to navigate the DOM tree and find elements that are related to a given element. Let\'s explore how to use it to our advantage.\\n\\n💬 Note\\n\\nAll the examples in this article…","guid":"https://www.30secondsofcode.org/js/s/get-ancestors-parents-siblings-children","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-08-20T16:00:00.540Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/flowering-hills-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/flowering-hills-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Browser "],"attachments":null,"extra":null,"language":null},{"title":"Render DOM elements with JavaScript","url":"https://www.30secondsofcode.org/js/s/render-dom-element","content":"Have you ever wondered how React\'s rendering works under the hood? How about implementing a simple version of it in vanilla JavaScript yourself? Luckily, this is not as complicated as it might sound. Let\'s dive in!
\\nThis implementation is for demonstration purposes only and lacks many features and optimizations present in React or Preact. If you only need a simple way to render a DOM tree, you might want to look into a full-fledged package.
\\nIn order to render a DOM tree in a specified container, you can create a function that takes an object representing the DOM tree and the container element. This function will recursively create and append DOM elements based on the given tree. But before we get to that, we need to define how we\'ll represent the DOM tree.
\\nReact\'s virtual DOM representation is similar to the object structure we\'ll be using. Each element is an object with a type
property representing the element\'s tag name, and a props
object containing the element\'s attributes, event listeners, and children.
const myElement = {\\n type: \'button\',\\n props: {\\n type: \'button\',\\n className: \'btn\',\\n onClick: () => alert(\'Clicked\'),\\n children: [{ props: { nodeValue: \'Click me\' } }]\\n }\\n};\\n// Equivalent to:\\n// <button\\n// type=\\"button\\"\\n// class=\\"btn\\"\\n// onClick=\\"alert(\'Clicked\')\\"\\n// >\\n// Click me\\n// </button>\\n
The special case of text elements is represented by an object without a type
property, only containing a props
object with a nodeValue
property.
Additionally, some special prop rules apply to props named a certain way. For example, event listeners are prefixed with \'on\'
, and children are stored in an array under the \'children\'
key. As we\'re using the Element
conventions, other attributes might have different names than in HTML (e.g., className
instead of class
).
Having a robust representation of the DOM tree, we can now create the function that renders the given tree in the specified container. The function will destructure the first argument into type
and props
, and use type
to determine if the given element is a text element.
Based on the element\'s type
, it will create the DOM element using either Document.createTextNode()
or Document.createElement()
. The difference between text and element nodes is that text nodes don\'t have a type
property.
In order to process the rest of the props
, we\'ll need two helper functions: one to check if a property is an event listener and another to check if it\'s an attribute. Using simple heuristics, we can determine listeners by checking if the property starts with \'on\'
and attributes by checking if the property is not a listener or \'children\'
.
Having set up these rules, we can then iterate over props
, using Object.keys()
, to add attributes to the DOM element and set event listeners as necessary. If the element has children, we\'ll use recursion to render them. Finally, we\'ll append the DOM element to the specified container
using Node.appendChild()
.
const renderElement = ({ type, props = {} }, container) => {\\n const isTextElement = !type;\\n const element = isTextElement\\n ? document.createTextNode(\'\')\\n : document.createElement(type);\\n\\n const isListener = p => p.startsWith(\'on\');\\n const isAttribute = p => !isListener(p) && p !== \'children\';\\n\\n Object.keys(props).forEach(p => {\\n if (isAttribute(p)) element[p] = props[p];\\n if (!isTextElement && isListener(p))\\n element.addEventListener(p.toLowerCase().slice(2), props[p]);\\n });\\n\\n if (!isTextElement && props.children && props.children.length)\\n props.children.forEach(childElement =>\\n renderElement(childElement, element)\\n );\\n\\n container.appendChild(element);\\n};\\n\\nconst myElement = {\\n type: \'button\',\\n props: {\\n type: \'button\',\\n className: \'btn\',\\n onClick: () => alert(\'Clicked\'),\\n children: [{ props: { nodeValue: \'Click me\' } }]\\n }\\n};\\n\\nrenderElement(myElement, document.body);\\n// Renders our <button> element in the body of the document\\n
And that\'s it! You\'ve just created a simple function that renders a DOM tree in a specified container. This is a great exercise to understand how React\'s rendering works under the hood and to get a better grasp of how to manipulate the DOM with JavaScript. You can now experiment with different elements and attributes to see how they render in the browser, or extend the function with additional features to suit your needs. Enjoy!
Last updated: July 6, 2024 View on GitHub
","description":"Have you ever wondered how React\'s rendering works under the hood? How about implementing a simple version of it in vanilla JavaScript yourself? Luckily, this is not as complicated as it might sound. Let\'s dive in!\\n\\n❗️ Caution\\n\\nThis implementation is for demonstration purposes…","guid":"https://www.30secondsofcode.org/js/s/render-dom-element","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-07-05T16:00:00.119Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/standing-stones-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/standing-stones-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Browser "],"attachments":null,"extra":null,"language":null},{"title":"Advanced React state hooks","url":"https://www.30secondsofcode.org/react/s/advanced-react-state-hooks","content":"React\'s toolbox is intentionally quite limited, providing you some very versatile building blocks to create your own abstractions. But, if you find useState()
too limited for your needs, and useReducer()
doesn\'t quite cut it either, how do you go about creating more advanced state management hooks? Let\'s deep dive into some advanced state management hooks.
useToggler
hookStarting off with a simple one, the useToggler
hook provides a boolean state variable that can be toggled between its two states. Instead of managing the state manually, you can simply call the toggleValue
function to toggle the state.
The implementation is rather simple, as well. You use the useState()
hook to create the value
state variable and its setter. Then, you create a function that toggles the value of the state variable and memoize it, using the useCallback()
hook. Finally, you return the value
state variable and the memoized function.
const useToggler = initialState => {\\n const [value, setValue] = React.useState(initialState);\\n const toggleValue = React.useCallback(() => setValue(prev => !prev), []);\\n\\n return [value, toggleValue];\\n};\\n\\nconst Switch = () => {\\n const [val, toggleVal] = useToggler(false);\\n return <button onClick={toggleVal}>{val ? \'ON\' : \'OFF\'}</button>;\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Switch />\\n);
\\nuseMap
hookThe Map
object is a very versatile data structure in JavaScript, but it\'s not directly supported by React\'s state management hooks. The useMap
hook creates a stateful Map
object and a set of functions to manipulate it.
Using the useState()
hook and the Map()
constructor, you create a new Map
from the initialValue
. Then, you use the useMemo()
hook to create a set of non-mutating actions that manipulate the state variable, using the state setter to create a new Map
every time. Finally, you return the map
state variable and the created actions
.
const useMap = initialValue => {\\n const [map, setMap] = React.useState(new Map(initialValue));\\n\\n const actions = React.useMemo(\\n () => ({\\n set: (key, value) =>\\n setMap(prevMap => {\\n const nextMap = new Map(prevMap);\\n nextMap.set(key, value);\\n return nextMap;\\n }),\\n remove: (key, value) =>\\n setMap(prevMap => {\\n const nextMap = new Map(prevMap);\\n nextMap.delete(key, value);\\n return nextMap;\\n }),\\n clear: () => setMap(new Map()),\\n }),\\n [setMap]\\n );\\n\\n return [map, actions];\\n};\\n\\nconst MyApp = () => {\\n const [map, { set, remove, clear }] = useMap([[\'apples\', 10]]);\\n\\n return (\\n <div>\\n <button onClick={() => set(Date.now(), new Date().toJSON())}>Add</button>\\n <button onClick={() => clear()}>Reset</button>\\n <button onClick={() => remove(\'apples\')} disabled={!map.has(\'apples\')}>\\n Remove apples\\n </button>\\n <pre>\\n {JSON.stringify(\\n [...map.entries()].reduce(\\n (acc, [key, value]) => ({ ...acc, [key]: value }),\\n {}\\n ),\\n null,\\n 2\\n )}\\n </pre>\\n </div>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <MyApp />\\n);
\\nuseSet
hookSimilar to useMap
, the useSet
hook creates a stateful Set
object and a set of functions to manipulate it. The implementation is very similar to the previous hook, but instead of using a Map
, you use a Set
.
const useSet = initialValue => {\\n const [set, setSet] = React.useState(new Set(initialValue));\\n\\n const actions = React.useMemo(\\n () => ({\\n add: item => setSet(prevSet => new Set([...prevSet, item])),\\n remove: item =>\\n setSet(prevSet => new Set([...prevSet].filter(i => i !== item))),\\n clear: () => setSet(new Set()),\\n }),\\n [setSet]\\n );\\n\\n return [set, actions];\\n};\\n\\nconst MyApp = () => {\\n const [set, { add, remove, clear }] = useSet(new Set([\'apples\']));\\n\\n return (\\n <div>\\n <button onClick={() => add(String(Date.now()))}>Add</button>\\n <button onClick={() => clear()}>Reset</button>\\n <button onClick={() => remove(\'apples\')} disabled={!set.has(\'apples\')}>\\n Remove apples\\n </button>\\n <pre>{JSON.stringify([...set], null, 2)}</pre>\\n </div>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <MyApp />\\n);
\\nuseDefault
hookSimilar to the previous hook, we might also need a hook that provides a default value if the state is null
or undefined
. The useDefault
hook does exactly that. It creates a stateful value with a default fallback if it\'s null
or undefined
, and a function to update it.
The approach is very similar to the previous hook. You use the useState()
hook to create the stateful value. Then, you check if the value is either null
or undefined
. If it is, you return the defaultState
, otherwise you return the actual value
state, alongside the setValue
function.
const useDefault = (defaultState, initialState) => {\\n const [value, setValue] = React.useState(initialState);\\n const isValueEmpty = value === undefined || value === null;\\n\\n return [isValueEmpty ? defaultState : value, setValue];\\n};\\n\\nconst UserCard = () => {\\n const [user, setUser] = useDefault({ name: \'Adam\' }, { name: \'John\' });\\n\\n return (\\n <>\\n <div>User: {user.name}</div>\\n <input onChange={e => setUser({ name: e.target.value })} />\\n <button onClick={() => setUser(null)}>Clear</button>\\n </>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <UserCard />\\n);
\\nuseGetSet
hookInstead of returning a single state variable and its setter, you might want to return a getter and a setter function. This is the job of the useGetSet
hook. It creates a stateful value, returning a getter and a setter function.
In order to implement this hook, you use the useRef()
hook to create a ref that holds the stateful value, initializing it with initialState
. Then, you use the useReducer()
hook that creates a new object every time it\'s updated and return its dispatch.
Finally, you use the useMemo()
hook to memoize a pair of functions. The first one will return the current value of the state
ref and the second one will update it and force a re-render.
const useGetSet = initialState => {\\n const state = React.useRef(initialState);\\n const [, update] = React.useReducer(() => ({}));\\n\\n return React.useMemo(\\n () => [\\n () => state.current,\\n newState => {\\n state.current = newState;\\n update();\\n },\\n ],\\n []\\n );\\n};\\n\\nconst Counter = () => {\\n const [getCount, setCount] = useGetSet(0);\\n const onClick = () => {\\n setTimeout(() => {\\n setCount(getCount() + 1);\\n }, 1_000);\\n };\\n\\n return <button onClick={onClick}>Count: {getCount()}</button>;\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Counter />\\n);
\\nuseMergeState
hookSimilar to the useReducer()
hook, the useMergeState
hook allows you to update the state by merging the new state provided with the existing one. It creates a stateful value and a function to update it by merging the new state provided.
All you need to do to implement it is use the useState()
hook to create a state variable, initializing it to initialState
. Then, create a function that will update the state variable by merging the new state provided with the existing one. If the new state is a function, call it with the previous state as the argument and use the result.
Omit the argument to initialize the state variable with an empty object ({}
).
const useMergeState = (initialState = {}) => {\\n const [value, setValue] = React.useState(initialState);\\n\\n const mergeState = newState => {\\n if (typeof newState === \'function\') newState = newState(value);\\n setValue({ ...value, ...newState });\\n };\\n\\n return [value, mergeState];\\n};\\n\\nconst MyApp = () => {\\n const [data, setData] = useMergeState({ name: \'John\', age: 20 });\\n\\n return (\\n <>\\n <input\\n value={data.name}\\n onChange={e => setData({ name: e.target.value })}\\n />\\n <button onClick={() => setData(({ age }) => ({ age: age - 1 }))}>\\n -\\n </button>\\n {data.age}\\n <button onClick={() => setData(({ age }) => ({ age: age + 1 }))}>\\n +\\n </button>\\n </>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <MyApp />\\n);
\\nusePrevious
hookThe usePrevious
hook is a very useful hook that stores the previous state or props. It\'s a custom hook that takes a value
and returns the previous value. It uses the useRef()
hook to create a ref for the value
and the useEffect()
hook to remember the latest value
.
const usePrevious = value => {\\n const ref = React.useRef();\\n React.useEffect(() => {\\n ref.current = value;\\n });\\n return ref.current;\\n};\\n\\nconst Counter = () => {\\n const [value, setValue] = React.useState(0);\\n const lastValue = usePrevious(value);\\n\\n return (\\n <div>\\n <p>\\n Current: {value} - Previous: {lastValue}\\n </p>\\n <button onClick={() => setValue(value + 1)}>Increment</button>\\n </div>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Counter />\\n);
\\nuseDebounce
hookSimilar to the usePrevious
hook, the useDebounce
hook is a custom hook that debounces the given value. It takes a value
and a delay
and returns the debounced value. It uses the useState()
hook to store the debounced value and the useEffect()
hook to update the debounced value every time the value
is updated.
Using setTimeout()
, it creates a timeout that delays invoking the setter of the previous state variable by delay
milliseconds. Then, it uses clearTimeout()
to clean up when dismounting the component. This is particularly useful when dealing with user input.
const useDebounce = (value, delay) => {\\n const [debouncedValue, setDebouncedValue] = React.useState(value);\\n\\n React.useEffect(() => {\\n const handler = setTimeout(() => {\\n setDebouncedValue(value);\\n }, delay);\\n\\n return () => {\\n clearTimeout(handler);\\n };\\n }, [value]);\\n\\n return debouncedValue;\\n};\\n\\nconst Counter = () => {\\n const [value, setValue] = React.useState(0);\\n const lastValue = useDebounce(value, 500);\\n\\n return (\\n <div>\\n <p>\\n Current: {value} - Debounced: {lastValue}\\n </p>\\n <button onClick={() => setValue(value + 1)}>Increment</button>\\n </div>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Counter />\\n);
\\nuseDelayedState
hookInstead of creating a stateful value immediately, you might want to delay its creation until some condition is met. This is where the useDelayedState
hook comes in. It creates a stateful value that is only updated if the condition
is met.
Implementing this hook requires the use of the useState()
and useEffect()
hooks. You create a stateful value containing the actual state
and a boolean, loaded
. Then, you update the stateful value if the condition
or loaded
changes. Finally, you create a function, updateState
, that only updates the state
value if loaded
is truthy.
const useDelayedState = (initialState, condition) => {\\n const [{ state, loaded }, setState] = React.useState({\\n state: null,\\n loaded: false,\\n });\\n\\n React.useEffect(() => {\\n if (!loaded && condition) setState({ state: initialState, loaded: true });\\n }, [condition, loaded]);\\n\\n const updateState = newState => {\\n if (!loaded) return;\\n setState({ state: newState, loaded });\\n };\\n\\n return [state, updateState];\\n};\\n\\nconst App = () => {\\n const [branches, setBranches] = React.useState([]);\\n const [selectedBranch, setSelectedBranch] = useDelayedState(\\n branches[0],\\n branches.length\\n );\\n\\n React.useEffect(() => {\\n const handle = setTimeout(() => {\\n setBranches([\'master\', \'staging\', \'test\', \'dev\']);\\n }, 2000);\\n return () => {\\n handle && clearTimeout(handle);\\n };\\n }, []);\\n\\n return (\\n <div>\\n <p>Selected branch: {selectedBranch}</p>\\n <select onChange={e => setSelectedBranch(e.target.value)}>\\n {branches.map(branch => (\\n <option key={branch} value={branch}>\\n {branch}\\n </option>\\n ))}\\n </select>\\n </div>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <App />\\n);
\\nuseForm
hookLast but not least, the useForm
hook can be used to create a stateful value from the fields in a form. It uses the useState()
hook to create a state variable for the values in the form and a function that will be called with an appropriate event by a form field to update the state variable accordingly.
const useForm = initialValues => {\\n const [values, setValues] = React.useState(initialValues);\\n\\n return [\\n values,\\n e => {\\n setValues({\\n ...values,\\n [e.target.name]: e.target.value\\n });\\n }\\n ];\\n};\\n\\nconst Form = () => {\\n const initialState = { email: \'\', password: \'\' };\\n const [values, setValues] = useForm(initialState);\\n\\n const handleSubmit = e => {\\n e.preventDefault();\\n console.log(values);\\n };\\n\\n return (\\n <form onSubmit={handleSubmit}>\\n <input type=\\"email\\" name=\\"email\\" onChange={setValues} />\\n <input type=\\"password\\" name=\\"password\\" onChange={setValues} />\\n <button type=\\"submit\\">Submit</button>\\n </form>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Form />\\n);
\\nLast updated: July 3, 2024 View On GitHub
","description":"React\'s toolbox is intentionally quite limited, providing you some very versatile building blocks to create your own abstractions. But, if you find useState() too limited for your needs, and useReducer() doesn\'t quite cut it either, how do you go about creating more advanced…","guid":"https://www.30secondsofcode.org/react/s/advanced-react-state-hooks","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-07-02T16:00:00.567Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/engine-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/engine-800.webp","type":"photo","width":360,"height":180}],"categories":[" React "," Hooks "],"attachments":null,"extra":null,"language":null},{"title":"Mapping data structures to React components","url":"https://www.30secondsofcode.org/react/s/data-mapping-components","content":"Have you ever wanted to quickly transform an array into a table or an object into a tree view? One of the benefits of React is that you can easily map data structures to components, even customizing them to your needs.
\\nThe simplest mapping is an array of primitives to a list of elements. All you really need is Array.prototype.map()
to render each item as a <li>
element. In addition to that, you should use a key
prop to help React identify each item. Finally, wrap the result in an <ol>
or <ul>
element, depending on whether you want an ordered or unordered list.
const DataList = ({ isOrdered = false, data }) => {\\n const list = data.map((val, i) => <li key={`${i}_${val}`}>{val}</li>);\\n return isOrdered ? <ol>{list}</ol> : <ul>{list}</ul>;\\n};\\n\\nconst names = [\'John\', \'Paul\', \'Mary\'];\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <>\\n <DataList data={names} />\\n <DataList data={names} isOrdered />\\n </>\\n);\\n
A very similar use-case is mapping an array of primitives to a table. The process is almost identical, but this time you render each item as a <tr>
element with two <td>
children. The first <td>
contains the index of the item, while the second one contains the item itself.
const DataTable = ({ data }) => {\\n return (\\n <table>\\n <thead>\\n <tr>\\n <th>ID</th>\\n <th>Value</th>\\n </tr>\\n </thead>\\n <tbody>\\n {data.map((val, i) => (\\n <tr key={`${i}_${val}`}>\\n <td>{i}</td>\\n <td>{val}</td>\\n </tr>\\n ))}\\n </tbody>\\n </table>\\n );\\n};\\n\\nconst people = [\'John\', \'Jesse\'];\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <DataTable data={people} />\\n);\\n
For more complex structures, such as arrays of objects, you\'ll need a more sophisticated component. Instead of rendering each item as a <tr>
element, you\'ll need to render each object as a <tr>
element with a <td>
for each key in the object. This way, you can create a table with rows dynamically created from an array of objects and a list of property names.
To get the keys and iterate over them, you\'ll combine Object.keys()
, Array.prototype.filter()
, Array.prototype.includes()
, and Array.prototype.reduce()
. This will produce a filteredData
array, containing all objects with the keys specified in propertyNames
.
This component does not work with nested objects and will break if there are nested objects inside any of the properties specified in propertyNames
.
const MappedTable = ({ data, propertyNames }) => {\\n let filteredData = data.map(v =>\\n Object.keys(v)\\n .filter(k => propertyNames.includes(k))\\n .reduce((acc, key) => ((acc[key] = v[key]), acc), {})\\n );\\n return (\\n <table>\\n <thead>\\n <tr>\\n {propertyNames.map(val => (\\n <th key={`h_${val}`}>{val}</th>\\n ))}\\n </tr>\\n </thead>\\n <tbody>\\n {filteredData.map((val, i) => (\\n <tr key={`i_${i}`}>\\n {propertyNames.map(p => (\\n <td key={`i_${i}_${p}`}>{val[p]}</td>\\n ))}\\n </tr>\\n ))}\\n </tbody>\\n </table>\\n );\\n};\\n\\nconst people = [\\n { name: \'John\', surname: \'Smith\', age: 42 },\\n { name: \'Adam\', surname: \'Smith\', gender: \'male\' }\\n];\\nconst propertyNames = [\'name\', \'surname\', \'age\'];\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <MappedTable data={people} propertyNames={propertyNames} />\\n);\\n
Finally, you can create a tree view component that can display nested objects. This component uses recursion to render nested objects as nested <TreeView>
components. The TreeView
component takes a data
prop, which is an object to render, and an optional name
prop, which is the name of the object.
Using the value of the isToggled
state variable, the component can toggle the visibility of the nested object. The isParentToggled
prop is used to pass the state of the parent object to the child object, allowing the child object to be toggled when the parent object is toggled.
For each key in the object, the component checks if the value is an object. If it is, it renders a nested <TreeView>
component. Otherwise, it renders a <p>
element with the key and value of the object.
.tree-element {\\n margin: 0 0 0 4px;\\n position: relative;\\n}\\n\\n.tree-element.is-child {\\n margin-left: 16px;\\n}\\n\\ndiv.tree-element::before {\\n content: \'\';\\n position: absolute;\\n top: 24px;\\n left: 1px;\\n height: calc(100% - 48px);\\n border-left: 1px solid gray;\\n}\\n\\np.tree-element {\\n margin-left: 16px;\\n}\\n\\n.toggler {\\n position: absolute;\\n top: 10px;\\n left: 0px;\\n width: 0;\\n height: 0;\\n border-top: 4px solid transparent;\\n border-bottom: 4px solid transparent;\\n border-left: 5px solid gray;\\n cursor: pointer;\\n}\\n\\n.toggler.closed {\\n transform: rotate(90deg);\\n}\\n\\n.collapsed {\\n display: none;\\n}\\n
const TreeView = ({\\n data,\\n toggled = true,\\n name = null,\\n isLast = true,\\n isChildElement = false,\\n isParentToggled = true\\n}) => {\\n const [isToggled, setIsToggled] = React.useState(toggled);\\n const isDataArray = Array.isArray(data);\\n\\n return (\\n <div\\n className={`tree-element ${isParentToggled && \'collapsed\'} ${\\n isChildElement && \'is-child\'\\n }`}\\n >\\n <span\\n className={isToggled ? \'toggler\' : \'toggler closed\'}\\n onClick={() => setIsToggled(!isToggled)}\\n />\\n {name ? <strong> {name}: </strong> : <span> </span>}\\n {isDataArray ? \'[\' : \'{\'}\\n {!isToggled && \'...\'}\\n {Object.keys(data).map((v, i, a) =>\\n typeof data[v] === \'object\' ? (\\n <TreeView\\n key={`${name}-${v}-${i}`}\\n data={data[v]}\\n isLast={i === a.length - 1}\\n name={isDataArray ? null : v}\\n isChildElement\\n isParentToggled={isParentToggled && isToggled}\\n />\\n ) : (\\n <p\\n key={`${name}-${v}-${i}`}\\n className={isToggled ? \'tree-element\' : \'tree-element collapsed\'}\\n >\\n {isDataArray ? \'\' : <strong>{v}: </strong>}\\n {data[v]}\\n {i === a.length - 1 ? \'\' : \',\'}\\n </p>\\n )\\n )}\\n {isDataArray ? \']\' : \'}\'}\\n {!isLast ? \',\' : \'\'}\\n </div>\\n );\\n};\\n\\nconst data = {\\n lorem: {\\n ipsum: \'dolor sit\',\\n amet: {\\n consectetur: \'adipiscing\',\\n elit: [\\n \'duis\',\\n \'vitae\',\\n {\\n semper: \'orci\'\\n },\\n {\\n est: \'sed ornare\'\\n },\\n \'etiam\',\\n [\'laoreet\', \'tincidunt\'],\\n [\'vestibulum\', \'ante\']\\n ]\\n },\\n ipsum: \'primis\'\\n }\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <TreeView data={data} name=\\"data\\" />\\n);
Last updated: June 18, 2024 View on GitHub
","description":"Have you ever wanted to quickly transform an array into a table or an object into a tree view? One of the benefits of React is that you can easily map data structures to components, even customizing them to your needs.\\n\\nData list\\n\\nThe simplest mapping is an array of primitives to…","guid":"https://www.30secondsofcode.org/react/s/data-mapping-components","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-06-17T16:00:00.954Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/interior-14-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/interior-14-800.webp","type":"photo","width":360,"height":180}],"categories":[" React "," Components "],"attachments":null,"extra":null,"language":null},{"title":"Collapsible content React components","url":"https://www.30secondsofcode.org/react/s/collapsable-content-components","content":"React makes collapsible content components a breeze with its state management and component composition. Simply put, you get full control over the content and its visibility, allowing you to create collapses, accordions, tabs and carousels.
\\nFor a simple collapsible content component, you need the useState()
hook to manage the state of the collapsible content. Then, using a <button>
to toggle the visibility of the content, you can make the content collapse or expand by changing the state.
For accessibility purposes, it\'s a good idea to use the aria-expanded
attribute to indicate the state of the collapsible content.
.collapse-button {\\n display: block;\\n width: 100%;\\n}\\n\\n.collapse-content.collapsed {\\n display: none;\\n}\\n\\n.collapsed-content.expanded {\\n display: block;\\n}\\n
const Collapse = ({ collapsed, children }) => {\\n const [isCollapsed, setIsCollapsed] = React.useState(collapsed);\\n\\n return (\\n <>\\n <button\\n className=\\"collapse-button\\"\\n onClick={() => setIsCollapsed(!isCollapsed)}\\n >\\n {isCollapsed ? \'Show\' : \'Hide\'} content\\n </button>\\n <div\\n className={`collapse-content ${isCollapsed ? \'collapsed\' : \'expanded\'}`}\\n aria-expanded={isCollapsed}\\n >\\n {children}\\n </div>\\n </>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Collapse>\\n <h1>This is a collapse</h1>\\n <p>Hello world!</p>\\n </Collapse>\\n);\\n
Accordions are very similar, but we need to manage the state a little differently, holding the index of the active accordion item. We can then toggle the visibility of the content based on the active index.
\\nIn order to make the component easier to read, we\'ll extract the AccordionItem
component to handle each accordion item. The Accordion
component will manage the state and render the items.
As an additional feature, we can pass a callback function to the Accordion
component to handle the click event on each accordion item. We may also want to filter out any non-AccordionItem
children.
.accordion-item.collapsed {\\n display: none;\\n}\\n\\n.accordion-item.expanded {\\n display: block;\\n}\\n\\n.accordion-button {\\n display: block;\\n width: 100%;\\n}\\n
const AccordionItem = ({ label, isCollapsed, handleClick, children }) => {\\n return (\\n <>\\n <button className=\\"accordion-button\\" onClick={handleClick}>\\n {label}\\n </button>\\n <div\\n className={`accordion-item ${isCollapsed ? \'collapsed\' : \'expanded\'}`}\\n aria-expanded={isCollapsed}\\n >\\n {children}\\n </div>\\n </>\\n );\\n};\\n\\nconst Accordion = ({ defaultIndex, onItemClick, children }) => {\\n const [bindIndex, setBindIndex] = React.useState(defaultIndex);\\n\\n const changeItem = itemIndex => {\\n if (typeof onItemClick === \'function\') onItemClick(itemIndex);\\n if (itemIndex !== bindIndex) setBindIndex(itemIndex);\\n };\\n const items = children.filter(item => item.type.name === \'AccordionItem\');\\n\\n return (\\n <>\\n {items.map(({ props }) => (\\n <AccordionItem\\n isCollapsed={bindIndex !== props.index}\\n label={props.label}\\n handleClick={() => changeItem(props.index)}\\n children={props.children}\\n />\\n ))}\\n </>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Accordion defaultIndex=\\"1\\" onItemClick={console.log}>\\n <AccordionItem label=\\"A\\" index=\\"1\\">\\n Lorem ipsum\\n </AccordionItem>\\n <AccordionItem label=\\"B\\" index=\\"2\\">\\n Dolor sit amet\\n </AccordionItem>\\n </Accordion>\\n);\\n
Tabs are virtually identical to an accordion, but with a different visual representation. The Tabs
component will manage the state and render the tab items. Again, we\'ll extract the TabItem
component to handle each tab item.
.tab-menu > button {\\n cursor: pointer;\\n padding: 8px 16px;\\n border: 0;\\n border-bottom: 2px solid transparent;\\n background: none;\\n}\\n\\n.tab-menu > button.focus {\\n border-bottom: 2px solid #007bef;\\n}\\n\\n.tab-menu > button:hover {\\n border-bottom: 2px solid #007bef;\\n}\\n\\n.tab-content {\\n display: none;\\n}\\n\\n.tab-content.selected {\\n display: block;\\n}\\n
const TabItem = props => <div {...props} />;\\n\\nconst Tabs = ({ defaultIndex = 0, onTabClick, children }) => {\\n const [bindIndex, setBindIndex] = React.useState(defaultIndex);\\n\\n const changeTab = newIndex => {\\n if (typeof onTabClick === \'function\') onTabClick(newIndex);\\n setBindIndex(newIndex);\\n };\\n const items = children.filter(item => item.type.name === \'TabItem\');\\n\\n return (\\n <div className=\\"wrapper\\">\\n <div className=\\"tab-menu\\">\\n {items.map(({ props: { index, label } }) => (\\n <button\\n key={`tab-btn-${index}`}\\n onClick={() => changeTab(index)}\\n className={bindIndex === index ? \'focus\' : \'\'}\\n >\\n {label}\\n </button>\\n ))}\\n </div>\\n <div className=\\"tab-view\\">\\n {items.map(({ props }) => (\\n <div\\n {...props}\\n className={`tab-content ${\\n bindIndex === props.index ? \'selected\' : \'\'\\n }`}\\n key={`tab-content-${props.index}`}\\n />\\n ))}\\n </div>\\n </div>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Tabs defaultIndex=\\"1\\" onTabClick={console.log}>\\n <TabItem label=\\"A\\" index=\\"1\\">\\n Lorem ipsum\\n </TabItem>\\n <TabItem label=\\"B\\" index=\\"2\\">\\n Dolor sit amet\\n </TabItem>\\n </Tabs>\\n);\\n
Finally, a carousel is a type of collapsible content that automatically scrolls through items. We can use the useEffect()
hook to update the active item index at regular intervals, with the help of setTimeout()
.
.carousel {\\n position: relative;\\n}\\n\\n.carousel-item {\\n position: absolute;\\n visibility: hidden;\\n}\\n\\n.carousel-item.visible {\\n visibility: visible;\\n}\\n
const Carousel = ({ carouselItems, ...rest }) => {\\n const [active, setActive] = React.useState(0);\\n let scrollInterval = null;\\n\\n React.useEffect(() => {\\n scrollInterval = setTimeout(() => {\\n setActive((active + 1) % carouselItems.length);\\n }, 2000);\\n return () => clearTimeout(scrollInterval);\\n });\\n\\n return (\\n <div className=\\"carousel\\">\\n {carouselItems.map((item, index) => {\\n const activeClass = active === index ? \' visible\' : \'\';\\n return React.cloneElement(item, {\\n ...rest,\\n className: `carousel-item${activeClass}`\\n });\\n })}\\n </div>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Carousel\\n carouselItems={[\\n <div>carousel item 1</div>,\\n <div>carousel item 2</div>,\\n <div>carousel item 3</div>\\n ]}\\n />\\n);
Last updated: June 17, 2024 View on GitHub
","description":"React makes collapsible content components a breeze with its state management and component composition. Simply put, you get full control over the content and its visibility, allowing you to create collapses, accordions, tabs and carousels.\\n\\nCollapsible content\\n\\nFor a simple colla…","guid":"https://www.30secondsofcode.org/react/s/collapsable-content-components","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-06-16T16:00:00.985Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/orange-wedges-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/orange-wedges-800.webp","type":"photo","width":360,"height":180}],"categories":[" React "," Components "],"attachments":null,"extra":null,"language":null},{"title":"Tooltips, alerts and modals in React","url":"https://www.30secondsofcode.org/react/s/tooltip-alert-modal","content":"Dialog components like tooltips, alerts and modals are essential for user interaction. They provide feedback, warnings and additional information to users. While a little more involved to implement, they\'re nothing that React can\'t handle.
\\nFor a simple tooltip component, you\'ll need to use the useState()
hook to manage the state of the tooltip. The tooltip should be displayed when the user hovers over the element and hidden when the user moves the cursor away. For that purpose, you can use the onMouseEnter
and onMouseLeave
events.
.tooltip-container {\\n position: relative;\\n}\\n\\n.tooltip-box {\\n position: absolute;\\n background: rgba(0, 0, 0, 0.7);\\n color: #fff;\\n padding: 5px;\\n border-radius: 5px;\\n top: calc(100% + 5px);\\n display: none;\\n}\\n\\n.tooltip-box.visible {\\n display: block;\\n}\\n\\n.tooltip-arrow {\\n position: absolute;\\n top: -10px;\\n left: 50%;\\n border-width: 5px;\\n border-style: solid;\\n border-color: transparent transparent rgba(0, 0, 0, 0.7) transparent;\\n}\\n
const Tooltip = ({ children, text, ...rest }) => {\\n const [show, setShow] = React.useState(false);\\n\\n return (\\n <div className=\\"tooltip-container\\">\\n <div className={show ? \'tooltip-box visible\' : \'tooltip-box\'}>\\n {text}\\n <span className=\\"tooltip-arrow\\" />\\n </div>\\n <div\\n onMouseEnter={() => setShow(true)}\\n onMouseLeave={() => setShow(false)}\\n {...rest}\\n >\\n {children}\\n </div>\\n </div>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Tooltip text=\\"Simple tooltip\\">\\n <button>Hover me!</button>\\n </Tooltip>\\n);\\n
In order to create an alert component, you\'ll need to manage the state of the alert. This state involves the isShown
state to determine if the alert should be displayed and the isLeaving
state to handle the closing animation.
The alert should be displayed when the component is rendered and hidden after a certain amount of time. You can use the useState()
hook to manage the state and the useEffect()
hook to handle the timeout with the help of setTimeout()
.
Additionally, we need to define a closeAlert()
function to handle the closing of the alert. This function will set the isLeaving
state to true
, wait for the specified timeout, and then set the isShown
state to false
.
@keyframes leave {\\n 0% { opacity: 1 }\\n 100% { opacity: 0 }\\n}\\n\\n.alert {\\n padding: 0.75rem 0.5rem;\\n margin-bottom: 0.5rem;\\n text-align: left;\\n padding-right: 40px;\\n border-radius: 4px;\\n font-size: 16px;\\n position: relative;\\n}\\n\\n.alert.warning {\\n color: #856404;\\n background-color: #fff3cd;\\n border-color: #ffeeba;\\n}\\n\\n.alert.error {\\n color: #721c24;\\n background-color: #f8d7da;\\n border-color: #f5c6cb;\\n}\\n\\n.alert.leaving {\\n animation: leave 0.5s forwards;\\n}\\n\\n.alert .close {\\n position: absolute;\\n top: 0;\\n right: 0;\\n padding: 0 0.75rem;\\n color: #333;\\n border: 0;\\n height: 100%;\\n cursor: pointer;\\n background: none;\\n font-weight: 600;\\n font-size: 16px;\\n}\\n\\n.alert .close::after {\\n content: \'x\';\\n}\\n
const Alert = ({ isDefaultShown = false, timeout = 250, type, message }) => {\\n const [isShown, setIsShown] = React.useState(isDefaultShown);\\n const [isLeaving, setIsLeaving] = React.useState(false);\\n\\n let timeoutId = null;\\n\\n React.useEffect(() => {\\n setIsShown(true);\\n return () => {\\n clearTimeout(timeoutId);\\n };\\n }, [isDefaultShown, timeout, timeoutId]);\\n\\n const closeAlert = () => {\\n setIsLeaving(true);\\n timeoutId = setTimeout(() => {\\n setIsLeaving(false);\\n setIsShown(false);\\n }, timeout);\\n };\\n\\n return (\\n isShown && (\\n <div\\n className={`alert ${type} ${isLeaving ? \'leaving\' : \'\'}`}\\n role=\\"alert\\"\\n >\\n <button className=\\"close\\" onClick={closeAlert} />\\n {message}\\n </div>\\n )\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <Alert type=\\"info\\" message=\\"This is info\\" />\\n);\\n
Modal dialogs essentially block the user from interacting with the rest of the application until they are closed. They are used for important messages, warnings, or additional information. For the modal dialog, CSS will do a lot of the heavy lifting.
\\nHowever, you\'ll also need to use the useEffect()
hook to handle the keydown
event. This event will close the modal when the user presses the Escape
key. The modal should be displayed when the isVisible
prop is true
and hidden when it\'s false
.
.modal {\\n position: fixed;\\n top: 0;\\n bottom: 0;\\n left: 0;\\n right: 0;\\n width: 100%;\\n z-index: 9999;\\n display: flex;\\n align-items: center;\\n justify-content: center;\\n background-color: rgba(0, 0, 0, 0.25);\\n animation-name: appear;\\n animation-duration: 300ms;\\n}\\n\\n.modal-dialog {\\n width: 100%;\\n max-width: 550px;\\n background: white;\\n position: relative;\\n margin: 0 20px;\\n max-height: calc(100vh - 40px);\\n text-align: left;\\n display: flex;\\n flex-direction: column;\\n overflow: hidden;\\n box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);\\n -webkit-animation-name: animatetop;\\n -webkit-animation-duration: 0.4s;\\n animation-name: slide-in;\\n animation-duration: 0.5s;\\n}\\n\\n.modal-header,\\n.modal-footer {\\n display: flex;\\n align-items: center;\\n padding: 1rem;\\n}\\n\\n.modal-header {\\n border-bottom: 1px solid #dbdbdb;\\n justify-content: space-between;\\n}\\n\\n.modal-footer {\\n border-top: 1px solid #dbdbdb;\\n justify-content: flex-end;\\n}\\n\\n.modal-close {\\n cursor: pointer;\\n padding: 1rem;\\n margin: -1rem -1rem -1rem auto;\\n}\\n\\n.modal-body {\\n overflow: auto;\\n}\\n\\n.modal-content {\\n padding: 1rem;\\n}\\n\\n@keyframes appear {\\n from {\\n opacity: 0;\\n }\\n to {\\n opacity: 1;\\n }\\n}\\n\\n@keyframes slide-in {\\n from {\\n transform: translateY(-150px);\\n }\\n to {\\n transform: translateY(0);\\n }\\n}\\n
const Modal = ({ isVisible = false, title, content, footer, onClose }) => {\\n const keydownHandler = ({ key }) => {\\n switch (key) {\\n case \'Escape\':\\n onClose();\\n break;\\n default:\\n }\\n };\\n\\n React.useEffect(() => {\\n document.addEventListener(\'keydown\', keydownHandler);\\n return () => document.removeEventListener(\'keydown\', keydownHandler);\\n });\\n\\n return !isVisible ? null : (\\n <div className=\\"modal\\" onClick={onClose}>\\n <div className=\\"modal-dialog\\" onClick={e => e.stopPropagation()}>\\n <div className=\\"modal-header\\">\\n <h3 className=\\"modal-title\\">{title}</h3>\\n <span className=\\"modal-close\\" onClick={onClose}>\\n ×\\n </span>\\n </div>\\n <div className=\\"modal-body\\">\\n <div className=\\"modal-content\\">{content}</div>\\n </div>\\n {footer && <div className=\\"modal-footer\\">{footer}</div>}\\n </div>\\n </div>\\n );\\n};\\n\\nconst App = () => {\\n const [isModal, setModal] = React.useState(false);\\n return (\\n <>\\n <button onClick={() => setModal(true)}>Click Here</button>\\n <Modal\\n isVisible={isModal}\\n title=\\"Modal Title\\"\\n content={<p>Add your content here</p>}\\n footer={<button>Cancel</button>}\\n onClose={() => setModal(false)}\\n />\\n </>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <App />\\n);
Last updated: June 16, 2024 View on GitHub
","description":"Dialog components like tooltips, alerts and modals are essential for user interaction. They provide feedback, warnings and additional information to users. While a little more involved to implement, they\'re nothing that React can\'t handle.\\n\\nTooltip\\n\\nFor a simple tooltip component,…","guid":"https://www.30secondsofcode.org/react/s/tooltip-alert-modal","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-06-15T16:00:00.505Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/yellow-white-mug-2-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/yellow-white-mug-2-800.webp","type":"photo","width":360,"height":180}],"categories":[" React "," Components "],"attachments":null,"extra":null,"language":null},{"title":"Tag input field","url":"https://www.30secondsofcode.org/react/s/tag-input","content":"Tag input fields have become a common feature in modern web applications. They allow users to add multiple tags to a form or search field. While this all might sound complicated, it\'s actually fairly simple to implement with React.
\\nStarting with the component\'s props, we\'ll only need to store the initial tags
array in a state variable. Then, using Array.prototype.map()
, we\'ll render the list of tags. The addTagData
method will be called when the user presses the Enter
key, and the removeTagData
method will be called when the user clicks the delete icon in the tag.
const TagInput = ({ tags }) => {\\n const [tagData, setTagData] = React.useState(tags);\\n\\n const removeTagData = indexToRemove => {\\n setTagData([...tagData.filter((_, index) => index !== indexToRemove)]);\\n };\\n\\n const addTagData = event => {\\n if (event.target.value !== \'\') {\\n setTagData([...tagData, event.target.value]);\\n event.target.value = \'\';\\n }\\n };\\n\\n return (\\n <div className=\\"tag-input\\">\\n <ul className=\\"tags\\">\\n {tagData.map((tag, index) => (\\n <li key={index} className=\\"tag\\">\\n <span className=\\"tag-title\\">{tag}</span>\\n <span\\n className=\\"tag-close-icon\\"\\n onClick={() => removeTagData(index)}\\n >\\n x\\n </span>\\n </li>\\n ))}\\n </ul>\\n <input\\n type=\\"text\\"\\n onKeyUp={event => (event.key === \'Enter\' ? addTagData(event) : null)}\\n placeholder=\\"Press enter to add a tag\\"\\n />\\n </div>\\n );\\n};\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <TagInput tags={[\'Nodejs\', \'MongoDB\']} />\\n);
\\n.tag-input {\\n display: flex;\\n flex-wrap: wrap;\\n min-height: 48px;\\n padding: 0 8px;\\n border: 1px solid #d6d8da;\\n border-radius: 6px;\\n}\\n\\n.tag-input input {\\n flex: 1;\\n border: none;\\n height: 46px;\\n font-size: 14px;\\n padding: 4px 0 0;\\n}\\n\\n.tag-input input:focus {\\n outline: transparent;\\n}\\n\\n.tags {\\n display: flex;\\n flex-wrap: wrap;\\n padding: 0;\\n margin: 8px 0 0;\\n}\\n\\n.tag {\\n width: auto;\\n height: 32px;\\n display: flex;\\n align-items: center;\\n justify-content: center;\\n color: #fff;\\n padding: 0 8px;\\n font-size: 14px;\\n list-style: none;\\n border-radius: 6px;\\n margin: 0 8px 8px 0;\\n background: #0052cc;\\n}\\n\\n.tag-title {\\n margin-top: 3px;\\n}\\n\\n.tag-close-icon {\\n display: block;\\n width: 16px;\\n height: 16px;\\n line-height: 16px;\\n text-align: center;\\n font-size: 14px;\\n margin-left: 8px;\\n color: #0052cc;\\n border-radius: 50%;\\n background: #fff;\\n cursor: pointer;\\n}
\\n \\nLast updated: June 15, 2024 View On GitHub
","description":"Tag input fields have become a common feature in modern web applications. They allow users to add multiple tags to a form or search field. While this all might sound complicated, it\'s actually fairly simple to implement with React.\\n\\nStarting with the component\'s props, we\'ll only…","guid":"https://www.30secondsofcode.org/react/s/tag-input","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-06-14T16:00:00.474Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/interior-4-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/interior-4-800.webp","type":"photo","width":360,"height":180}],"categories":[" React "," Components "],"attachments":null,"extra":null,"language":null},{"title":"Stateful checkbox with multiple selection","url":"https://www.30secondsofcode.org/react/s/multiselect-checkbox","content":"A group of checkboxes can be used to allow users to select multiple options from a list. However, managing individual inputs is usually a hassle, so you may want to roll up a React component that handles this for you.
\\nTo create a stateful checkbox list that supports multiple selections, you can use the useState()
hook to manage the state of the checkboxes. Using Array.prototype.splice()
and the spread operator (...
), you can update the state variable with the new value of the checkbox, when it changes. Finally, you can call a callback function with the selected values.
For the rendering of the component, you\'ll have to use Array.prototype.map()
to map the state variable to individual <input type=\\"checkbox\\">
elements. Wrap each one in a <label>
, binding the onClick
handler to the toggle
function.
const MultiselectCheckbox = ({ options, onChange }) => {\\n const [data, setData] = React.useState(options);\\n\\n const toggle = index => {\\n const newData = [...data];\\n newData.splice(index, 1, {\\n label: data[index].label,\\n checked: !data[index].checked\\n });\\n setData(newData);\\n onChange(newData.filter(x => x.checked));\\n };\\n\\n return (\\n <>\\n {data.map((item, index) => (\\n <label key={item.label}>\\n <input\\n readOnly\\n type=\\"checkbox\\"\\n checked={item.checked || false}\\n onClick={() => toggle(index)}\\n />\\n {item.label}\\n </label>\\n ))}\\n </>\\n );\\n};\\n\\nconst options = [{ label: \'Item One\' }, { label: \'Item Two\' }];\\n\\nReactDOM.createRoot(document.getElementById(\'root\')).render(\\n <MultiselectCheckbox\\n options={options}\\n onChange={data => {\\n console.log(data);\\n }}\\n />\\n);
Last updated: June 13, 2024 View on GitHub
","description":"A group of checkboxes can be used to allow users to select multiple options from a list. However, managing individual inputs is usually a hassle, so you may want to roll up a React component that handles this for you.\\n\\nTo create a stateful checkbox list that supports multiple…","guid":"https://www.30secondsofcode.org/react/s/multiselect-checkbox","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-06-12T16:00:00.228Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/violin-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/violin-800.webp","type":"photo","width":360,"height":180}],"categories":[" React "," Components "],"attachments":null,"extra":null,"language":null},{"title":"Convert between CSV and JavaScript arrays, objects or JSON","url":"https://www.30secondsofcode.org/js/s/convert-csv-to-array-object-or-json","content":"Comma-separated values (CSV) is a simple text format for storing tabular data. Each line in a CSV file represents a row, and each value in a row is separated by a delimiter, usually a comma. As CSV is used very often for exchanging data between different systems, it\'s important to know how to convert between JavaScript arrays or objects and CSV data.
\\nAs CSV format RFC 4180 is not standardized, there are many variations in how CSV data is formatted. This guide will cover the most common cases, but you may need to adjust the code to fit your specific use-case (see the table below for edge cases and how they are handled).
\\nSerializing data to CSV is generally straightforward. Using Array.prototype.join()
with a delimiter of your choice, you can easily create a single row from an array of values.
const serializeRow = (row, delimiter = \',\') => row.join(delimiter);\\n\\nserializeRow([\'a\', \'b\']);\\n// \'a,b\'\\nserializeRow([\'a\', \'b\'], \';\');\\n// \'a;b\'\\n
However, there are edge cases that need handling. These include empty values, values that contain the delimiter character, values that contain newlines, and values that contain double quotes. Here\'s a handy table of edge cases and how to handle them:
\\nCase | \\nSample value | \\nCSV representation | \\n
---|---|---|
Simple case | \\n[\'a\',\'b\'] | \\na,b | \\n
Empty value | \\n[\'a\',null] | \\na, | \\n
Value contains delimiter | \\n[\'a,b\',\'c\'] | \\n\\"a,b\\",c | \\n
Value contains newline | \\n[\'a\\\\nb\',\'c\'] | \\n\\"a\\\\\\\\nb\\",c | \\n
Value contains double quotes | \\n[\'a\\"b\',\'c\'] | \\n\\"a\\"\\"b\\",c | \\n
That being said, we can update our serializeRow
function to handle these edge cases. We should also extract the logic for serializing a single value to a separate function. Additionally, let\'s define a function that checks for empty values, so we can customize it as needed.
const isEmptyValue = value =>\\n value === null || value === undefined || Number.isNaN(value);\\n\\nconst serializeValue = (value, delimiter = \',\') => {\\n if (isEmptyValue(value)) return \'\';\\n value = `${value}`;\\n if (\\n value.includes(delimiter) ||\\n value.includes(\'\\\\n\') ||\\n value.includes(\'\\"\')\\n )\\n return `\\"${value.replace(/\\"/g, \'\\"\\"\').replace(/\\\\n/g, \'\\\\\\\\n\')}\\"`;\\n return value;\\n};\\n\\nconst serializeRow = (row, delimiter = \',\') =>\\n row.map(value => serializeValue(`value`, delimiter)).join(delimiter);\\n\\nserializeRow([\'a\', \'b\']);\\n// \'a,b\'\\nserializeRow([\'a\', null]);\\n// \'a,\'\\nserializeRow([\'a,b\', \'c\']);\\n// \'\\"a,b\\",c\'\\nserializeRow([\'a\\\\nb\', \'c\']);\\n// \'\\"a\\\\\\\\nb\\",c\'\\nserializeRow([\'a\\"b\', \'c\']);\\n// \'\\"a\\"\\"b\\",c\'\\n
Now that we have a way to serialize values and rows, we can move on to more complex use-cases, such as serializing a whole array of values or objects.
\\nSerializing an array of values into CSV is essentially just moving from one dimension to two. We can use Array.prototype.map()
to serialize each row and then join them together with newline characters (\\\\n
).
const arrayToCSV = (arr, delimiter = \',\') =>\\n arr.map(row => serializeRow(row, delimiter)).join(\'\\\\n\');\\n\\narrayToCSV([[\'a\', \'b\'], [\'c\', \'d\']]);\\n// \'a,b\\\\nc,d\'\\narrayToCSV([[\'a,b\', \'c\'], [\'d\', \'e\\\\n\\"f\\"\']]) ;\\n// \'\\"a,b\\",c\\\\nd,\\"e\\\\\\\\n\\"\\"f\\"\\"\\"\'\\n
Converting JavaScript objects to CSV is a bit more involved. As objects contain key-value pairs, we need to serialize the values in the correct order based on the keys. We can either use a predefined list of keys or extract them from the objects themselves. Additionally, we\'ll have to include the keys as the header (first row) of the CSV.
\\nIn short, extracting the header row from the objects involves collecting all unique keys from the objects. We can achieve this by using Array.prototype.reduce()
and a Set
to keep track of the unique keys.
const extractHeaders = (arr) =>\\n [...arr.reduce((acc, obj) => {\\n Object.keys(obj).forEach((key) => acc.add(key));\\n return acc;\\n }, new Set())];\\n\\nextractHeaders([\\n { a: 1, b: 2 },\\n { a: 3, b: 4, c: 5 },\\n { a: 6 },\\n { b: 7 },\\n]);\\n// [\'a\', \'b\', \'c\']\\n
Now that we have the headers, we can use them to serialize the objects into CSV. In order to allow for more flexibility, we\'ll pass the headers as an argument to the function. This way, we can omit headers or include only specific ones. The default value for the argument should be the result of extractHeaders
. Moreover, we may want to omit the header row altogether, so we\'ll add an optional argument for that as well.
As a rule of thumb, the objects need to be JSON-serializable, or at least the subset of keys we\'re interested in.
\\nconst objectToCSV = (\\n arr,\\n headers = extractHeaders(arr),\\n omitHeaders = false,\\n delimiter = \',\'\\n) => {\\n const headerRow = serializeRow(headers, delimiter);\\n const bodyRows = arr.map(obj =>\\n serializeRow(\\n headers.map(key => obj[key]),\\n delimiter\\n )\\n );\\n return omitHeaders\\n ? bodyRows.join(\'\\\\n\')\\n : [headerRow, ...bodyRows].join(\'\\\\n\');\\n};\\n\\nobjectToCSV(\\n [{ a: 1, b: 2 }, { a: 3, b: 4, c: 5 }, { a: 6 }, { b: 7 }]\\n);\\n// \'a,b,c\\\\n1,2,\\\\n3,4,5\\\\n6,,\\\\n,7,\'\\nobjectToCSV(\\n [{ a: 1, b: 2 }, { a: 3, b: 4, c: 5 }, { a: 6 }, { b: 7 }],\\n [\'a\', \'b\']\\n);\\n// \'a,b\\\\n1,2\\\\n3,4\\\\n6,\\\\n,7\'\\nobjectToCSV(\\n [{ a: 1, b: 2 }, { a: 3, b: 4, c: 5 }, { a: 6 }, { b: 7 }],\\n [\'a\', \'b\'],\\n true\\n);\\n// \'1,2\\\\n3,4\\\\n6,\\\\n,7\'\\n
Converting JSON data to CSV is a simple extension of the previous functions. We can parse the JSON data into an array of objects and then use the objectToCSV
function to serialize it.
const JSONToCSV = (json, headers, omitHeaders) =>\\n objectToCSV(JSON.parse(json), headers, omitHeaders);\\n\\nJSONToCSV(\\n \'[{\\"a\\":1,\\"b\\":2},{\\"a\\":3,\\"b\\":4,\\"c\\":5},{\\"a\\":6},{\\"b\\":7}]\'\\n);\\n// \'a,b,c\\\\n1,2,\\\\n3,4,5\\\\n6,,\\\\n,7,\'\\nJSONToCSV(\\n \'[{\\"a\\":1,\\"b\\":2},{\\"a\\":3,\\"b\\":4,\\"c\\":5},{\\"a\\":6},{\\"b\\":7}]\',\\n [\'a\', \'b\']\\n);\\n// \'a,b\\\\n1,2\\\\n3,4\\\\n6,\\\\n,7\'\\nJSONToCSV(\\n \'[{\\"a\\":1,\\"b\\":2},{\\"a\\":3,\\"b\\":4,\\"c\\":5},{\\"a\\":6},{\\"b\\":7}]\',\\n [\'a\', \'b\'],\\n true\\n);\\n// \'1,2\\\\n3,4\\\\n6,\\\\n,7\'\\n
Parsing CSV data back into arrays or objects can be trickier than serialization. The same edge cases need to be handled when deserializing the data. Let\'s focus on row deserialization first. While we can use regular expressions for a more elegant solution, we\'ll stick to a simple while
loop for this implementation for readability.
Starting from the beginning of the row, we\'ll iterate over each character and build up the values. We will keep track if we\'re inside quotations, while also skipping two subsequent quotes if they\'re part of an escaped quote. When we encounter the delimiter character and we\'re not inside quotations, we\'ll push the value to the array and move on to the next one. We\'ll also use String.prototype.replace()
to handle escaped quotes and newlines and to trim enclosing quotes from the values.
const deserializeRow = (row, delimiter = \',\') => {\\n const values = [];\\n let index = 0, matchStart = 0, isInsideQuotations = false;\\n while (true) {\\n if (index === row.length) {\\n values.push(row.slice(matchStart, index));\\n break;\\n }\\n const char = row[index];\\n if (char === delimiter && !isInsideQuotations) {\\n values.push(\\n row\\n .slice(matchStart, index)\\n .replace(/^\\"|\\"$/g, \'\')\\n .replace(/\\"\\"/g, \'\\"\')\\n .replace(/\\\\\\\\n/g, \'\\\\n\')\\n );\\n matchStart = index + 1;\\n }\\n if (char === \'\\"\')\\n if (row[index + 1] === \'\\"\') index += 1;\\n else isInsideQuotations = !isInsideQuotations;\\n index += 1;\\n }\\n return values;\\n};\\n\\ndeserializeRow(\'a,b\');\\n// [\'a\', \'b\']\\ndeserializeRow(\'a,\');\\n// [\'a\', \'\']\\ndeserializeRow(\'\\"a,b\\",c\');\\n// [\'a,b\', \'c\']\\ndeserializeRow(\'\\"a\\"\\"b\\",c\');\\n// [\'a\\"b\', \'c\']\\ndeserializeRow(\'\\"a\\\\\\\\nb\\",c\');\\n// [\'a\\\\nb\', \'c\']\\n
Having a function to deserialize a single row, we can now move on to deserializing the whole CSV data. We\'ll split the data into rows and use the deserializeRow
function to extract the values.
All values are strings after deserialization. To convert them back to their original types, you\'ll have to do so manually. As this will depend on your specific use-case, it will not be covered here, however you can easily add an optional argument to parse values as needed.
\\nconst deserializeCSV = (data, delimiter = \',\') =>\\n data.split(\'\\\\n\').map(row => deserializeRow(row, delimiter));\\n\\ndeserializeCSV(\'a,b\\\\nc,d\');\\n// [[\'a\', \'b\'], [\'c\', \'d\']]\\ndeserializeCSV(\'a;b\\\\nc;d\', \';\');\\n// [[\'a\', \'b\'], [\'c\', \'d\']]\\ndeserializeCSV(\'\\"a,b\\",c\\\\n\\"a\\"\\"b\\",c\');\\n// [[\'a,b\', \'c\'], [\'a\\"b\', \'c\']]\\ndeserializeCSV(\'a,\\\\n\\"a\\\\\\\\nb\\",c\');\\n// [[\'a\', \'\'], [\'a\\\\nb\', \'c\']]\\n
The previous function is a good starting point for deserializing CSV data into arrays. However, we might want to omit the header row. We can add an optional argument to the function to handle this.
\\nconst CSVToArray = (data, delimiter = \',\', omitHeader = false) => {\\n const rows = data.split(\'\\\\n\');\\n if (omitHeader) rows.shift();\\n return rows.map(row => deserializeRow(row, delimiter));\\n};\\n\\nCSVToArray(\'a,b\\\\nc,d\');\\n// [[\'a\', \'b\'], [\'c\', \'d\']]\\nCSVToArray(\'a;b\\\\nc;d\', \';\');\\n// [[\'a\', \'b\'], [\'c\', \'d\']]\\nCSVToArray(\'col1,col2\\\\na,b\\\\nc,d\', \',\', true);\\n// [[\'a\', \'b\'], [\'c\', \'d\']]\\n
Converting CSV data into objects needs a couple of extra steps. We need to extract the header row to use as keys for the objects. We can then use these keys to create objects for each row. We\'ll use the same deserializeRow
function to extract the values for each row.
const CSVtoObject = (data, delimiter = \',\') => {\\n const rows = data.split(\'\\\\n\');\\n const headers = deserializeRow(rows.shift(), delimiter);\\n return rows.map((row) => {\\n const values = deserializeRow(row, delimiter);\\n return headers.reduce((obj, key, index) => {\\n obj[key] = values[index];\\n return obj;\\n }, {});\\n });\\n};\\n\\nCSVtoObject(\'col1,col2\\\\na,b\\\\nc,d\');\\n// [{\'col1\': \'a\', \'col2\': \'b\'}, {\'col1\': \'c\', \'col2\': \'d\'}]\\nCSVtoObject(\'col1;col2\\\\na;b\\\\nc;d\', \';\');\\n// [{\'col1\': \'a\', \'col2\': \'b\'}, {\'col1\': \'c\', \'col2\': \'d\'}]\\n
Converting CSV data into JSON is a simple extension of the previous functions. We can use the CSVtoObject
function to parse the CSV data into objects and then use JSON.stringify
to serialize it.
const CSVToJSON = (data, delimiter = \',\') =>\\n JSON.stringify(CSVtoObject(data, delimiter));\\n\\nCSVToJSON(\'col1,col2\\\\na,b\\\\nc,d\');\\n// \'[{\\"col1\\":\\"a\\",\\"col2\\":\\"b\\"},{\\"col1\\":\\"c\\",\\"col2\\":\\"d\\"}]\'\\nCSVToJSON(\'col1;col2\\\\na;b\\\\nc;d\', \';\');\\n// \'[{\\"col1\\":\\"a\\",\\"col2\\":\\"b\\"},{\\"col1\\":\\"c\\",\\"col2\\":\\"d\\"}]\'\\n
This guide covered most of the groundwork necessary to convert between CSV data and JavaScript arrays, objects, or JSON. The provided functions can be customized or extended, depending on your specific needs. Remember that CSV is not a standardized format, so you may need to adjust the code to fit your specific use-case.
\\nconst isEmptyValue = value =>\\n value === null || value === undefined || Number.isNaN(value);\\n\\nconst serializeValue = (value, delimiter = \',\') => {\\n if (isEmptyValue(value)) return \'\';\\n value = `${value}`;\\n if (\\n value.includes(delimiter) ||\\n value.includes(\'\\\\n\') ||\\n value.includes(\'\\"\')\\n )\\n return `\\"${value.replace(/\\"/g, \'\\"\\"\').replace(/\\\\n/g, \'\\\\\\\\n\')}\\"`;\\n return value;\\n};\\n\\nconst serializeRow = (row, delimiter = \',\') =>\\n row.map(value => serializeValue(`value`, delimiter)).join(delimiter);\\n\\nconst extractHeaders = (arr) =>\\n [...arr.reduce((acc, obj) => {\\n Object.keys(obj).forEach((key) => acc.add(key));\\n return acc;\\n }, new Set())];\\n\\nconst arrayToCSV = (arr, delimiter = \',\') =>\\n arr.map(row => serializeRow(row, delimiter)).join(\'\\\\n\');\\n\\nconst objectToCSV = (\\n arr,\\n headers = extractHeaders(arr),\\n omitHeaders = false,\\n delimiter = \',\'\\n) => {\\n const headerRow = serializeRow(headers, delimiter);\\n const bodyRows = arr.map(obj =>\\n serializeRow(\\n headers.map(key => obj[key]),\\n delimiter\\n )\\n );\\n return omitHeaders\\n ? bodyRows.join(\'\\\\n\')\\n : [headerRow, ...bodyRows].join(\'\\\\n\');\\n};\\n\\nconst JSONToCSV = (json, headers, omitHeaders) =>\\n objectToCSV(JSON.parse(json), headers, omitHeaders);\\n
const deserializeRow = (row, delimiter = \',\') => {\\n const values = [];\\n let index = 0, matchStart = 0, isInsideQuotations = false;\\n while (true) {\\n if (index === row.length) {\\n values.push(row.slice(matchStart, index));\\n break;\\n }\\n const char = row[index];\\n if (char === delimiter && !isInsideQuotations) {\\n values.push(\\n row\\n .slice(matchStart, index)\\n .replace(/^\\"|\\"$/g, \'\')\\n .replace(/\\"\\"/g, \'\\"\')\\n .replace(/\\\\\\\\n/g, \'\\\\n\')\\n );\\n matchStart = index + 1;\\n }\\n if (char === \'\\"\')\\n if (row[index + 1] === \'\\"\') index += 1;\\n else isInsideQuotations = !isInsideQuotations;\\n index += 1;\\n }\\n return values;\\n};\\n\\nconst deserializeCSV = (data, delimiter = \',\') =>\\n data.split(\'\\\\n\').map(row => deserializeRow(row, delimiter));\\n\\nconst CSVToArray = (data, delimiter = \',\', omitHeader = false) => {\\n const rows = data.split(\'\\\\n\');\\n if (omitHeader) rows.shift();\\n return rows.map(row => deserializeRow(row, delimiter));\\n};\\n\\nconst CSVtoObject = (data, delimiter = \',\') => {\\n const rows = data.split(\'\\\\n\');\\n const headers = deserializeRow(rows.shift(), delimiter);\\n return rows.map((row) => {\\n const values = deserializeRow(row, delimiter);\\n return headers.reduce((obj, key, index) => {\\n obj[key] = values[index];\\n return obj;\\n }, {});\\n });\\n};\\n\\nconst CSVToJSON = (data, delimiter = \',\') =>\\n JSON.stringify(CSVtoObject(data, delimiter));\\n
Last updated: June 8, 2024 View on GitHub
","description":"Comma-separated values (CSV) is a simple text format for storing tabular data. Each line in a CSV file represents a row, and each value in a row is separated by a delimiter, usually a comma. As CSV is used very often for exchanging data between different systems, it\'s important…","guid":"https://www.30secondsofcode.org/js/s/convert-csv-to-array-object-or-json","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-06-07T16:00:00.874Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/tropical-bike-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/tropical-bike-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Array "],"attachments":null,"extra":null,"language":null},{"title":"React rendering","url":"https://www.30secondsofcode.org/react/s/rendering","content":"Rendering is the process during which React moves down the component tree starting at the root, looking for all the components flagged for update, asking them to describe their desired UI structure based on the current combination of props
and state
. For each flagged component, React will call its render()
method (for class components) or FunctionComponent()
(for function components), and save the output produced after converting the JSX result into a plain JS object, using React.createElement()
.
After collecting the render output from the entire component tree, React will diff the new tree (the virtual DOM) with the current DOM tree and collect the list of changes that need to be made to the DOM to produce the desired UI structure. After this process, known as reconciliation, React applies all the calculated changes to the DOM.
\\nConceptually, this work is divided into two phases:
\\nAfter the commit phase is complete, React will run componentDidMount
and componentDidUpdate
lifecycle methods, as well as useLayoutEffect()
and, after a short timeout, useEffect()
hooks.
Two key takeaways here are the following:
\\nAfter the initial render has completed, there are a few different things that will cause a re-render:
\\nthis.setState()
(class components)this.forceUpdate()
(class components)useState()
setters (function components)useReducer()
dispatches (function components)ReactDOM.render()
again (on the root component)React\'s default behavior is to recursively render all child components inside of it when a parent component is rendered. This means that it does not care if a component\'s props
have changed - as long as the parent component rendered, its children will render unconditionally.
To put this another way, calling setState()
in the root component without any other changes, will cause React to re-render every single component in the component tree. Most likely, most of the components will return the exact same render output as the last render, meaning React will not have to make any changes to the DOM, but the rendering and diffing calculations will be performed regardless, taking time and effort.
As we\'ve seen already, rendering is React\'s way of knowing if it needs to make changes in the DOM, but there are certain cases where work and calculations performed during the render phase can be a wasted effort. After all, if a component\'s render output is identical, there will be no DOM updates, thus the work wasn\'t necessary.
\\nRender output should always be based on the current combination of props
and state
, so it is possible to know ahead of time if a component\'s render output will be the same so long as its props
and state
remain unchanged. This is the key observation on top of which optimizing React rendering is based, as it hinges on our code doing less work and skipping component rendering when possible.
React offers a handful of APIs that allow us to optimize the rendering process:
\\nshouldComponentUpdate
(class components): Lifecycle method, called before rendering, returning a boolean (false
to skip rendering, true
to proceed as usual). Logic can vary as necessary, but the most common case is checking if the component\'s props
and state
have changed.React.PureComponent
(class components): Base class that implements the previously described props
and state
change check in its shouldComponentUpdate
lifecycle method.React.memo()
(any component): Higher-order component (HOC) that wraps any given component. It implements the same kind of functionality as React.PureComponent
, but can also wrap function components.All of these techniques use shallow equality for comparisons. Skipping rendering a component means skipping the default recursive behavior of rendering children, effectively skipping the whole subtree of components.
\\nPassing new references as props
to a child component doesn\'t usually matter, as it will re-render regardless when the parent changes. However, if you are trying to optimize a child component\'s rendering by checking if its props
have changed, passing new references will cause a render. This behavior is ok if the new references are updated data, but if it\'s a new reference to the same callback function passed down by the parent, it\'s rather problematic.
This is less of an issue in class components, as they have instance methods whose references don\'t change, although any sort of generated callbacks passed down to a component\'s children can result in new references. As far as function components are concerned, React provides the useMemo()
hook for memoizing values, and the useCallback()
hook specifically for memoizing callbacks.
useMemo()
and useCallback()
can provide performance benefits but, as with any other memoization usage, it\'s important to think about their necessity and the net benefit they provide in the long run. A good rule of thumb is to consider using them for pure functional components that re-render often with the same props
and/or might do heavy calculations and avoid them elsewhere.
React Developer Tools provide a handy Profiler tab that allows you to visualize and explore the rendering process of your React applications. Under this tab, you will find a settings icon which will allow you to Highlight updates when components render, as well as Record why each component rendered while profiling - I highly suggest ticking both of them. Recording the initial render and re-renders of the website can provide invaluable insights about the application\'s bottlenecks and issues and also highlight optimization opportunities (often using one of the techniques described above).
\\nFinally, remember that React\'s development builds are significantly slower than production builds, so take all the measurements you see with a grain of salt as absolute times in development are not a valuable metric. Identifying unnecessary renders, memoization and optimization opportunities, as well as potential bottlenecks is where you should focus.
\\nReact\'s Context API provides a way to pass data through the component tree without using props
, but should not be used for state management as it requires manual updating. Any component inside a context\'s Provider
can access the data in the context instance using a Consumer
component or, for function components only, the useContext()
hook.
When a new reference is passed to a context Provider
it will cause any connected components to update. React will look for any components consuming the context in the component tree and update them to reflect the change in the context\'s value. Passing a new object to a context Provider
is essentially a new reference, as the context holds a single value (in this case an object).
By default, any update to a parent component that renders a context Provider
will cause all of the child components to re-render regardless of changes in the context, due to React\'s rendering process. To avoid re-rendering child components when a parent changes, memoization can be used, which will cause React to skip the whole subtree of a skipped component.
When the context is updated, React additionally checks for components consuming the context down the subtree. This allows context-consuming components under a memoized parent that does not re-render to consume the updated context and render as necessary. After a context-consuming component re-renders, React will keep on recursively rendering its child components as usual.
\\nOftentimes, it\'s a good idea to memoize the component immediately under a context Provider
. That way updates to the parent component will not cause a re-render for the whole subtree, but only the components that consume the context.
React-Redux provides bindings for Redux, a state container for JavaScript applications, and works a little differently from React\'s Context API. One of the key differences is that React-Redux only re-renders components that need to render, due to the fact that components subscribed to the Redux store read the latest store state, diff the values and force re-render only if the relevant data has changed, while React is not involved at all in the subscription callback process.
\\nWhile this most likely means that fewer components will have to re-render compared to using a context, React-Redux always executes its mapStateToProps
and useSelector
functions for every connected component in the tree whenever the store state is updated. These calculations are usually less expensive than React\'s rendering, but if there are costly calculations performed or new references returned when they shouldn\'t, it might become problematic.
React-Redux provides two ways of connecting to its store, performing the necessary work and returning the combined props
:
connect
(any component): Higher-order component (HOC) that wraps any given componentuseSelector
(function components): Hook called inside function componentsconnect
acts a lot like memoizing a React component (i.e. using React.PureComponent
or React.memo()
), updating the wrapped component only when the combined props
have changed. This means that passing new references from the parent or the passed functions will still cause a re-render. Components wrapped with connect
usually read smaller pieces of data from the store state, are less likely to re-render due to that and usually affect fewer components down their tree.
On the other hand, useSelector
has no way of stopping a component from rendering when its parent component renders. When exclusively using useSelector
, larger parts of the component tree will re-render due to Redux store updates than they would with connect
, since there aren\'t other components using connect
to prevent them from doing so. You can use React.memo()
as necessary, to optimize this behavior by preventing unnecessary re-rendering.
\\nLast updated: June 4, 2024 View On GitHub
","description":"Rendering introduction\\n\\nRendering is the process during which React moves down the component tree starting at the root, looking for all the components flagged for update, asking them to describe their desired UI structure based on the current combination of props and state. For…","guid":"https://www.30secondsofcode.org/react/s/rendering","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-06-03T16:00:00.963Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/comic-glasses-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/comic-glasses-800.webp","type":"photo","width":360,"height":180}],"categories":[" React "],"attachments":null,"extra":null,"language":null},{"title":"Modeling money, currencies & exchange rates using JavaScript","url":"https://www.30secondsofcode.org/js/s/modeling-money-currency-exchange-rates","content":"Working with money, currencies and exchange rates is pretty common, yet it\'s no easy task no matter the language. While JavaScript doesn\'t have a lot of useful features built into it, Intl
can give us a head start with some parts of the process.
The first step in modeling any sort of monetary value is to have a structure for currency. Luckily, Intl
has this problem solved for us. You can use Intl.NumberFormat
with style: \'currency\'
to get a formatter for a specific currency. This formatter can then be used to format a number into a currency string.
We\'ve previously covered formatting a number into a currency string, which might cover some simpler use cases.
\\nIn order to retrieve all supported currencies, we can use Intl.supportedValuesOf()
with \'currency\'
as the argument. This will return an array of the ISO 4217 currency codes supported by the environment. Then, using Map()
, Array.prototype.map()
and Intl.NumberFormat
, we can create an object for all currencies, that will format values on demand.
const isoCodes = Intl.supportedValuesOf(\'currency\');\\n\\nconst currencyFields = [\'symbol\', \'narrowSymbol\', \'name\'];\\n\\nconst allCurrencies = new Map(\\n isoCodes.map(code => {\\n const format = currencyDisplay => value =>\\n Intl.NumberFormat(undefined, {\\n style: \'currency\',\\n currency: code,\\n currencyDisplay,\\n })\\n .format(value)\\n .trim();\\n return [code, Object.freeze({ code, format })];\\n })\\n);\\n// Returns a Map object with all currency information\\n// {\\n// \'USD\': { code: \'USD\', format: [Function] },\\n// \'EUR\': { code: \'EUR\', format: [Function] },\\n// ...\\n// }\\n
Notice how Object.freeze()
is used to prevent the object from being modified. This is important because we don\'t want to accidentally change the currency information.
Having set up all the currency information, we need a way to retrieve it when we need it. Getting the same currency object for the same currency code will be important later down the line for comparisons. As we have a Map
object, we can use Map.prototype.get()
to retrieve the currency object. As a safeguard, we should ensure the currency code matches the key, using String.prototype.toUpperCase()
.
const getCurrencyFromCode = code => {\\n const isoCode = code.toUpperCase();\\n return allCurrencies.get(isoCode);\\n};\\n\\nconst currency = getCurrencyFromCode(\'usd\');\\ncurrency.format(\'symbol\')(1000); // \'$1,000.00\'\\n
We can then create a simple static class to handle currency retrieval. Apart from a simple get()
method, we can also add a wrap()
method. This method will return the same currency object if it\'s passed as an argument. Otherwise, it will retrieve the currency object using the get()
method.
class Currency {\\n static get(code) {\\n const currency = getCurrencyFromCode(code);\\n if (!currency)\\n throw new RangeError(`Invalid currency ISO code \\"${code}\\"`);\\n return currency;\\n }\\n\\n static wrap(currency) {\\n if (\\n typeof currency === \'object\' &&\\n getCurrencyFromCode(currency.code) === currency\\n )\\n return currency;\\n\\n return Currency.get(currency);\\n }\\n}\\n\\nconst usd = Currency.get(\'usd\');\\nusd.format(\'symbol\')(1000); // \'$1,000.00\'\\n\\nconst usd2 = Currency.wrap(usd);\\nusd === usd2; // true\\n
A monetary value is simply a data structure that contains a value and a currency. Implementing a class for that is fairly simple, using the Currency
class from before. We can then use the currency object to format the value as needed.
class Money {\\n value;\\n currency;\\n\\n constructor(value, currency) {\\n this.value = Number.parseFloat(value);\\n this.currency = Currency.wrap(currency);\\n }\\n\\n format(currencyDisplay = \'symbol\') {\\n return this.currency.format(currencyDisplay)(this.value);\\n }\\n}\\n\\nconst money = new Money(1000, \'usd\');\\nmoney.format(); // \'$1,000.00\'\\nmoney.format(\'code\'); // \'USD 1,000.00\'\\n
This Money
implementation is fairly naive, as the values are stored as floating point numbers. This can lead to precision errors, especially when performing mathematical operations. You might want to consider more robust numeric representations, if you\'re using this in a production environment.
Performing mathematical operations with money is a bit more complex. We need to ensure that the currency is the same for both operands. We\'ll later cover how to handle exchange rates, but for now, we\'ll focus on the basic operations.
\\nOddly enough, multiplication and division are the low hanging fruits, as you can only multiply or divide by a scalar value. Similarly, the modulo and quotient operations can be implemented without any additional complexity.
\\nclass Money {\\n // ...\\n\\n multiply(num) {\\n return new Money(this.value * num, this.currency);\\n }\\n\\n divide(num) {\\n return new Money(this.value / num, this.currency);\\n }\\n\\n div(num) {\\n return new Money(Math.floor(this.value / num), this.currency);\\n }\\n\\n mod(num) {\\n return new Money(this.value % num, this.currency);\\n }\\n\\n divmod(num) {\\n return [this.div(num), this.mod(num)];\\n }\\n}\\n\\nconst money = new Money(1000, \'usd\');\\nmoney.multiply(2).format(); // \'$2,000.00\'\\nmoney.divide(2).format(); // \'$500.00\'\\nmoney.div(3).format(); // \'$333.00\'\\nmoney.mod(3).format(); // \'$1.00\'\\n
Addition and subtraction are the tough ones. We need to ensure that the currency is the same for both operands, which can be done by comparing the currency codes. If it\'s not, we can throw an error for the time being.
\\nclass Money {\\n // ...\\n\\n add(money) {\\n if (this.currency !== money.currency)\\n throw new Error(\'Cannot add money with different currencies\');\\n return new Money(this.value + money.value, this.currency);\\n }\\n\\n subtract(money) {\\n if (this.currency !== money.currency)\\n throw new Error(\'Cannot subtract money with different currencies\');\\n return new Money(this.value - money.value, this.currency);\\n }\\n}\\n\\nconst money1 = new Money(1000, \'usd\');\\nconst money2 = new Money(500, \'usd\');\\nmoney1.add(money2).format(); // \'$1,500.00\'\\nmoney1.subtract(money2).format(); // \'$500.00\'\\n
Finally, equality and comparison operations can be implemented by comparing the values and the currency codes. Again, we\'ll have to deal with the currency codes being different, but we\'ll throw an error for now.
\\nclass Money {\\n // ...\\n\\n equals(money) {\\n if (this.currency !== money.currency)\\n throw new Error(\'Cannot compare money with different currencies\');\\n return this.value === money.value;\\n }\\n\\n greaterThan(money) {\\n if (this.currency !== money.currency)\\n throw new Error(\'Cannot compare money with different currencies\');\\n return this.value > money.value;\\n }\\n\\n lessThan(money) {\\n if (this.currency !== money.currency)\\n throw new Error(\'Cannot compare money with different currencies\');\\n return this.value < money.value;\\n }\\n}\\n\\nconst money1 = new Money(1000, \'usd\');\\nconst money2 = new Money(500, \'usd\');\\nmoney1.equals(money2); // false\\nmoney1.greaterThan(money2); // true\\nmoney1.lessThan(money2); // false\\n
An exchange rate is simply a ratio between two currencies. Instead of modeling it as a class, using a wrapper object that contains multiple exchange rates provides more utility. We\'ll be calling this object a Bank
.
The Bank
class will contain a Map
object that maps currency pairs to exchange rates. We can add exchange rates using Map.prototype.set()
and retrieve them using Map.prototype.get()
. In order to keep things neat and ensure we can pass either currencies or ISO codes, we can use the Currency.wrap()
method from before.
class Bank {\\n exchangeRates;\\n\\n constructor() {\\n this.exchangeRates = new Map();\\n }\\n\\n setRate(from, to, rate) {\\n const fromCurrency = Currency.wrap(from);\\n const toCurrency = Currency.wrap(to);\\n const exchangeRate = Number.parseFloat(rate);\\n\\n this.exchangeRates.set(\\n `${fromCurrency.code} -> ${toCurrency.code}`,\\n exchangeRate\\n );\\n\\n return this;\\n }\\n\\n getRate(from, to) {\\n const fromCurrency = Currency.wrap(from);\\n const toCurrency = Currency.wrap(to);\\n\\n return this.exchangeRates.get(\\n `${fromCurrency.code} -> ${toCurrency.code}`\\n );\\n }\\n}\\n\\nconst bank = new Bank();\\nbank.setRate(\'usd\', \'eur\', 0.85);\\nbank.getRate(\'usd\', \'eur\'); // 0.85\\n
Converting money from one currency to another is a matter of multiplying the value by the exchange rate. The responsibility for exchanging money is part of the Bank
class, as it\'s the one that holds the exchange rates.
class Bank {\\n // ...\\n\\n exchange(money, to) {\\n if (!(money instanceof Money))\\n throw new TypeError(`${money} is not an instance of Money`);\\n\\n const toCurrency = Currency.wrap(to);\\n if (toCurrency === money.currency) return money;\\n\\n const exchangeRate = this.getRate(money.currency, toCurrency);\\n if (!exchangeRate)\\n throw new TypeError(\\n `No exchange rate found for ${money.currency.code} to ${toCurrency.code}`\\n );\\n return new Money(money.value * exchangeRate, toCurrency);\\n }\\n}\\n\\nconst bank = new Bank();\\nbank.setRate(\'usd\', \'eur\', 0.85);\\nconst money = new Money(1000, \'usd\');\\nbank.exchange(money, \'eur\').format(); // \'€850.00\'\\n
Using the Bank
class to exchange money works, but it\'s a lot of work to do every time. The more practical scenario would be to reference a Bank
instance from each Money
object and use it to exchange money.
class Money {\\n value;\\n currency;\\n bank;\\n\\n constructor(value, currency, bank) {\\n this.value = Number.parseFloat(value);\\n this.currency = Currency.wrap(currency);\\n if (!(bank instanceof Bank))\\n throw new TypeError(`${bank} is not an instance of Bank`);\\n this.bank = bank;\\n }\\n\\n // ...\\n\\n exchangeTo(currency) {\\n return this.bank.exchange(this, currency);\\n }\\n}\\n\\nconst bank = new Bank();\\nbank.setRate(\'usd\', \'eur\', 0.85);\\nconst money = new Money(1000, \'usd\', bank);\\nmoney.exchangeTo(\'eur\').format(); // \'€850.00\'\\n
However, passing the Bank
instance every time we create a Money
object is not practical. In most scenarios, you\'ll only ever have a single instance, which can be easily added to the class as a static property. This allows for Money
instances to default to the same instance, making exchanges easier.
class Bank {\\n static defaultBank;\\n\\n // ...\\n}\\n\\nclass Money {\\n // ...\\n\\n constructor(value, currency, bank = Bank.defaultBank) {\\n this.value = Number.parseFloat(value);\\n this.currency = Currency.wrap(currency);\\n if (!(bank instanceof Bank))\\n throw new TypeError(`${bank} is not an instance of Bank`);\\n this.bank = bank;\\n }\\n\\n // ...\\n}\\n\\nconst bank = new Bank();\\nbank.setRate(\'usd\', \'eur\', 0.85);\\nBank.defaultBank = bank;\\n\\nconst money = new Money(1000, \'usd\');\\nmoney.exchangeTo(\'eur\').format(); // \'€850.00\'\\n
If you plan on implementing historical exchange rates, you can use multiple Bank
instances, one for each date. This way, you can easily switch between them when needed.
Having set up exchange rates, we can now perform mathematical operations with money in different currencies. We need only check if two Money
objects are in the same currency before performing the operation. If they\'re not, we can exchange one of them to the other currency.
class Money {\\n // ...\\n\\n add(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return new Money(this.value + money.value, this.currency);\\n }\\n\\n subtract(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return new Money(this.value - money.value, this.currency);\\n }\\n\\n equals(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value === money.value;\\n }\\n\\n greaterThan(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value > money.value;\\n }\\n\\n lessThan(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value < money.value;\\n }\\n\\n // ...\\n}\\n\\nconst bank = new Bank();\\nbank.setRate(\'usd\', \'eur\', 0.85);\\nbank.setRate(\'eur\', \'usd\', 1.18);\\nBank.defaultBank = bank;\\n\\nconst money1 = new Money(1000, \'usd\');\\nconst money2 = new Money(500, \'eur\');\\nmoney1.add(money2).format(); // \'$1,590.00\'\\nmoney1.subtract(money2).format(); // \'$410.00\'\\nmoney1.equals(money2); // false\\nmoney1.greaterThan(money2); // true\\nmoney1.lessThan(money2); // false\\n
Implementing a basic structure for money, currencies and exchange rates is a lot of work, but it\'s fairly straightforward once you get the basics down. There\'s plenty of improvements that you can make to this implementation, such as adding more mathematical operations, or handling historical exchange rates. However, this should give you a good starting point for any project that requires handling money.
\\nconst isoCodes = Intl.supportedValuesOf(\'currency\');\\n\\nconst currencyFields = [\'symbol\', \'narrowSymbol\', \'name\'];\\n\\nconst allCurrencies = new Map(\\n isoCodes.map(code => {\\n const format = currencyDisplay => value =>\\n Intl.NumberFormat(undefined, {\\n style: \'currency\',\\n currency: code,\\n currencyDisplay,\\n })\\n .format(value)\\n .trim();\\n return [code, Object.freeze({ code, format })];\\n })\\n);\\n\\nconst getCurrencyFromCode = code => {\\n const isoCode = code.toUpperCase();\\n return allCurrencies.get(isoCode);\\n};\\n\\nclass Currency {\\n static get(code) {\\n const currency = getCurrencyFromCode(code);\\n if (!currency)\\n throw new RangeError(`Invalid currency ISO code \\"${code}\\"`);\\n return currency;\\n }\\n\\n static wrap(currency) {\\n if (\\n typeof currency === \'object\' &&\\n getCurrencyFromCode(currency.code) === currency\\n )\\n return currency;\\n\\n return Currency.get(currency);\\n }\\n}\\n
class Bank {\\n static defaultBank;\\n\\n exchangeRates;\\n\\n constructor() {\\n this.exchangeRates = new Map();\\n }\\n\\n setRate(from, to, rate) {\\n const fromCurrency = Currency.wrap(from);\\n const toCurrency = Currency.wrap(to);\\n const exchangeRate = Number.parseFloat(rate);\\n\\n this.exchangeRates.set(\\n `${fromCurrency.code} -> ${toCurrency.code}`,\\n exchangeRate\\n );\\n\\n return this;\\n }\\n\\n getRate(from, to) {\\n const fromCurrency = Currency.wrap(from);\\n const toCurrency = Currency.wrap(to);\\n\\n return this.exchangeRates.get(\\n `${fromCurrency.code} -> ${toCurrency.code}`\\n );\\n }\\n\\n exchange(money, to) {\\n if (!(money instanceof Money))\\n throw new TypeError(`${money} is not an instance of Money`);\\n\\n const toCurrency = Currency.wrap(to);\\n if (toCurrency === money.currency) return money;\\n\\n const exchangeRate = this.getRate(money.currency, toCurrency);\\n if (!exchangeRate)\\n throw new TypeError(\\n `No exchange rate found for ${money.currency.code} to ${toCurrency.code}`\\n );\\n return new Money(money.value * exchangeRate, toCurrency);\\n }\\n}\\n
class Money {\\n value;\\n currency;\\n bank;\\n\\n constructor(value, currency, bank = Bank.defaultBank) {\\n this.value = Number.parseFloat(value);\\n this.currency = Currency.wrap(currency);\\n if (!(bank instanceof Bank))\\n throw new TypeError(`${bank} is not an instance of Bank`);\\n this.bank = bank;\\n }\\n\\n format(currencyDisplay = \'symbol\') {\\n return this.currency.format(currencyDisplay)(this.value);\\n }\\n\\n exchangeTo(currency) {\\n return this.bank.exchange(this, currency);\\n }\\n\\n add(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return new Money(this.value + money.value, this.currency);\\n }\\n\\n subtract(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return new Money(this.value - money.value, this.currency);\\n }\\n\\n multiply(num) {\\n return new Money(this.value * num, this.currency);\\n }\\n\\n divide(num) {\\n return new Money(this.value / num, this.currency);\\n }\\n\\n div(num) {\\n return new Money(Math.floor(this.value / num), this.currency);\\n }\\n\\n mod(num) {\\n return new Money(this.value % num, this.currency);\\n }\\n\\n divmod(num) {\\n return [this.div(num), this.mod(num)];\\n }\\n\\n equals(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value === money.value;\\n }\\n\\n greaterThan(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value > money.value;\\n }\\n\\n lessThan(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value < money.value;\\n }\\n}\\n
Last updated: April 24, 2024 View on GitHub
","description":"Working with money, currencies and exchange rates is pretty common, yet it\'s no easy task no matter the language. While JavaScript doesn\'t have a lot of useful features built into it, Intl can give us a head start with some parts of the process.\\n\\nModeling currency\\n\\nThe first step…","guid":"https://www.30secondsofcode.org/js/s/modeling-money-currency-exchange-rates","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-04-23T16:00:00.282Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/money-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/money-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Math "],"attachments":null,"extra":null,"language":null},{"title":"Formatting numeric values in JavaScript","url":"https://www.30secondsofcode.org/js/s/number-formatting","content":"Number formatting is one of the most common presentational tasks you\'ll encounter coding for the web. While there are some built-in methods, it\'s often necessary to roll up your own solution for a specific use case. Let\'s explore a few common scenarios and how to handle them.
\\nYou may not be familiar with JavaScript\'s numeric separators, which are used in the examples below. They\'re syntactic sugar that make large numeric values more readable.
\\nIn fixed-point notation, a number is represented with a fixed number of digits after the decimal point. However, we often want to remove trailing zeros from the result.
\\nIn order to do so, we can use Number.prototype.toFixed()
to convert the number to a fixed-point notation string. Then, using Number.parseFloat()
, we can convert the fixed-point notation string to a number, removing trailing zeros. Finally, we can use a template literal to convert the number to a string.
const toOptionalFixed = (num, digits) =>\\n `${Number.parseFloat(num.toFixed(digits))}`;\\n\\ntoOptionalFixed(1, 2); // \'1\'\\ntoOptionalFixed(1.001, 2); // \'1\'\\ntoOptionalFixed(1.500, 2); // \'1.5\'\\n
Rounding a number to a specific number of decimal places is pretty common. We can use Math.round()
and template literals to round the number to the specified number of digits. Omitting the second argument, decimals
, will round to an integer.
const round = (n, decimals = 0) =>\\n Number(`${Math.round(`${n}e${decimals}`)}e-${decimals}`);\\n\\nround(1.005, 2); // 1.01\\n
This function returns a number, instead of a string. This is intentional, as we might want to use the rounded number in further calculations.
\\nConverting a number of milliseconds to a human-readable format is a matter of dividing the number by the appropriate values and creating a string for each value. In the following snippet, we\'ll use an object that contains the appropriate values for day
, hour
, minute
, second
, and millisecond
, but you can easily adapt it to different needs.
We can use Object.entries()
with Array.prototype.filter()
to keep only non-zero values. Then, we can use Array.prototype.map()
to create the string for each value, pluralizing appropriately. Finally, we can use Array.prototype.join()
to combine the values into a string.
const formatDuration = ms => {\\n if (ms < 0) ms = -ms;\\n const time = {\\n day: Math.floor(ms / 86_400_000),\\n hour: Math.floor(ms / 3_600_000) % 24,\\n minute: Math.floor(ms / 60_000) % 60,\\n second: Math.floor(ms / 1_000) % 60,\\n millisecond: Math.floor(ms) % 1_000\\n };\\n return Object.entries(time)\\n .filter(val => val[1] !== 0)\\n .map(([key, val]) => `${val} ${key}${val !== 1 ? \'s\' : \'\'}`)\\n .join(\', \');\\n};\\n\\nformatDuration(1_001);\\n// \'1 second, 1 millisecond\'\\nformatDuration(34_325_055_574);\\n// \'397 days, 6 hours, 44 minutes, 15 seconds, 574 milliseconds\'\\n
Similarly, formatting the number of seconds to ISO format is also a handful of divisions away. However, we need to handle the sign separately.
\\nWe can use Array.prototype.map()
in combination with Math.floor()
and String.prototype.padStart()
to convert each segment to a string. Finally, we can use Array.prototype.join()
to combine the values into a string.
const formatSeconds = s => {\\n const [hour, minute, second, sign] =\\n s > 0\\n ? [s / 3_600, (s / 60) % 60, s % 60, \'\']\\n : [-s / 3_600, (-s / 60) % 60, -s % 60, \'-\'];\\n\\n return (\\n sign +\\n [hour, minute, second]\\n .map(v => `${Math.floor(v)}`.padStart(2, \'0\'))\\n .join(\':\')\\n );\\n};\\n\\nformatSeconds(200); // \'00:03:20\'\\nformatSeconds(-200); // \'-00:03:20\'\\nformatSeconds(99_999); // \'27:46:39\'\\n
Formatting a number to a locale-sensitive string is also easy using Number.prototype.toLocaleString()
. This method allows us to format numbers using the local number format separators.
const formatNumber = num => num.toLocaleString();\\n\\nformatNumber(123_456); // \'123,456\' in `en-US`\\nformatNumber(15_675_436_903); // \'15.675.436.903\' in `de-DE`\\n
If we want to format a number to a specific locale, we can pass the locale as the first argument to Number.prototype.toLocaleString()
. For example, here\'s how to format a number to use the decimal mark, using the en-US
locale.
const toDecimalMark = num => num.toLocaleString(\'en-US\');\\n\\ntoDecimalMark(12_305_030_388.9087); // \'12,305,030,388.909\'\\n
When working with currency, it\'s important to use the appropriate formatting. Luckily, JavaScript\'s Intl.NumberFormat
makes this easy, allowing us to format numbers as currency strings. All we have to do is specify the currency and language format, along with style: \'currency\'
.
const toCurrency = (n, curr, LanguageFormat = undefined) =>\\n Intl.NumberFormat(LanguageFormat, {\\n style: \'currency\',\\n currency: curr,\\n }).format(n);\\n\\ntoCurrency(123_456.789, \'EUR\');\\n// €123,456.79 | currency: Euro | currencyLangFormat: Local\\ntoCurrency(123_456.789, \'USD\', \'en-us\');\\n// $123,456.79 | currency: US Dollar | currencyLangFormat: English (USA)\\ntoCurrency(123_456.789, \'USD\', \'fa\');\\n// ۱۲۳٬۴۵۶٫۷۹ $ | currency: US Dollar | currencyLangFormat: Farsi\\ntoCurrency(322_342_436_423.2435, \'JPY\');\\n// ¥322,342,436,423 | currency: Japanese Yen | currencyLangFormat: Local\\ntoCurrency(322_342_436_423.2435, \'JPY\', \'fi\');\\n// 322 342 436 423 ¥ | currency: Japanese Yen | currencyLangFormat: Finnish\\n
Converting a number to its ordinal form (e.g. 1
to 1st
, 2
to 2nd
, 3
to 3rd
, etc.) is also fairly simple, using Intl.PluralRules
. We can use Intl.PluralRules.prototype.select()
to get the correct pluralization category for the number, and then use a lookup dictionary to get the correct suffix. In order for this to produce the correct result, we need to specify the correct locale and the type: \'ordinal\'
option when constructing the object.
const ordinalsEnUS = {\\n one: \'st\',\\n two: \'nd\',\\n few: \'rd\',\\n many: \'th\',\\n zero: \'th\',\\n other: \'th\',\\n};\\n\\nconst toOrdinalSuffix = (num, locale = \'en-US\', ordinals = ordinalsEnUS) => {\\n const pluralRules = new Intl.PluralRules(locale, { type: \'ordinal\' });\\n return `${num}${ordinals[pluralRules.select(num)]}`;\\n};\\n\\ntoOrdinalSuffix(1); // \'1st\'\\ntoOrdinalSuffix(2); // \'2nd\'\\ntoOrdinalSuffix(3); // \'3rd\'\\ntoOrdinalSuffix(4); // \'4th\'\\ntoOrdinalSuffix(123); // \'123rd\'\\n
Last but not least, you might need to pad a number with leading zeros to a specific length. We can use String.prototype.padStart()
to pad the number to the specified length, after converting it to a string.
const padNumber = (n, l) => `${n}`.padStart(l, \'0\');\\n\\npadNumber(1_234, 6); // \'001234\'
Last updated: February 14, 2024 View on GitHub
","description":"Number formatting is one of the most common presentational tasks you\'ll encounter coding for the web. While there are some built-in methods, it\'s often necessary to roll up your own solution for a specific use case. Let\'s explore a few common scenarios and how to handle them.\\n\\n�…","guid":"https://www.30secondsofcode.org/js/s/number-formatting","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-02-13T16:00:00.154Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/white-chapel-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/white-chapel-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Math "],"attachments":null,"extra":null,"language":null},{"title":"Case conversion in JavaScript","url":"https://www.30secondsofcode.org/js/s/string-case-conversion","content":"Different programming languages and frameworks have different conventions for naming variables, functions and classes. It\'s often necessary to convert strings between different cases, which is where this guide comes in.
\\nBefore we can convert a string to a different case, we need to be able to identify the boundaries between words. While a naive approach could rely on spaces or other delimiters to separate words, this approach is not robust enough to handle all cases. Regular expressions provide a far more robust solution to this problem. After much experimentation, I\'ve found the following regular expression to be the most robust:
\\nconst r = /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g;\\n
This looks intimidating even to me, not gonna lie. Let\'s break it down into its constituent parts:
\\n[A-Z]{2,}
matches two or more consecutive uppercase letters. This is useful for identifying acronyms like XML
or HTML
.(?=[A-Z][a-z]+[0-9]*|\\\\b)
is a lookahead assertion that matches a word boundary.[A-Z]?[a-z]+[0-9]*
matches a word starting with an optional uppercase letter, followed by one or more lowercase letters and zero or more digits.[A-Z]
matches a single uppercase letter.[0-9]+
matches one or more digits.g
is a global flag that allows the regular expression to match all occurrences in the string.The camel case naming convention requires that the first letter of each word is capitalized, except for the first word. For example someName
is camel case, but SomeName
is not. This convention is used in JavaScript for naming variables, functions and classes.
In order to convert a string to camel case, we need to:
\\nString.prototype.match()
to break the string into words using an appropriate regexp.Array.prototype.map()
to transform individual words. Use String.prototype.slice()
and String.prototype.toUpperCase()
to capitalize the first letter of each word, and String.prototype.toLowerCase()
to lowercase the rest.Array.prototype.join()
to combine the words into a single string.String.prototype.toLowerCase()
to lowercase the first letter of the final string.const toCamelCase = str => {\\n const s =\\n str &&\\n str\\n .match(\\n /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g\\n )\\n .map(x => x.slice(0, 1).toUpperCase() + x.slice(1).toLowerCase())\\n .join(\'\');\\n return s.slice(0, 1).toLowerCase() + s.slice(1);\\n};\\n\\ntoCamelCase(\'some_database_field_name\'); // \'someDatabaseFieldName\'\\ntoCamelCase(\'Some label that needs to be camelized\');\\n// \'someLabelThatNeedsToBeCamelized\'\\ntoCamelCase(\'some-javascript-property\'); // \'someJavascriptProperty\'\\ntoCamelCase(\'some-mixed_string with spaces_underscores-and-hyphens\');\\n// \'someMixedStringWithSpacesUnderscoresAndHyphens\'\\n
Pascal case is most often used in object-oriented languages like Java or C#. Pascal case strings have the first letter of each word capitalized. For example SomeName
is Pascal case, but someName
is not.
The process for converting a string to Pascal case is very similar to the process for converting a string to camel case. The only difference is that we don\'t need to lowercase the first letter of the final string.
\\nconst toPascalCase = str =>\\n str\\n .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)\\n .map(x => x.slice(0, 1).toUpperCase() + x.slice(1).toLowerCase())\\n .join(\'\');\\n\\ntoPascalCase(\'some_database_field_name\'); // \'SomeDatabaseFieldName\'\\ntoPascalCase(\'Some label that needs to be pascalized\');\\n// \'SomeLabelThatNeedsToBePascalized\'\\ntoPascalCase(\'some-javascript-property\'); // \'SomeJavascriptProperty\'\\ntoPascalCase(\'some-mixed_string with spaces_underscores-and-hyphens\');\\n// \'SomeMixedStringWithSpacesUnderscoresAndHyphens\'\\n
Kebab case is most often used in URL slugs. Kebab case strings are all lowercase, with words separated by hyphens. For example some-name
is kebab case, but some-Name
is not.
In order to convert a string to kebab case, we need only lowercase each word and join them with a hyphen.
\\nconst toKebabCase = str =>\\n str &&\\n str\\n .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)\\n .map(x => x.toLowerCase())\\n .join(\'-\');\\n\\ntoKebabCase(\'camelCase\'); // \'camel-case\'\\ntoKebabCase(\'some text\'); // \'some-text\'\\ntoKebabCase(\'some-mixed_string With spaces_underscores-and-hyphens\');\\n// \'some-mixed-string-with-spaces-underscores-and-hyphens\'\\ntoKebabCase(\'AllThe-small Things\'); // \'all-the-small-things\'\\ntoKebabCase(\'IAmEditingSomeXMLAndHTML\');\\n// \'i-am-editing-some-xml-and-html\'\\n
Snake case is most often used in languages such as Python or Ruby. Snake case strings are all lowercase, with words separated by underscores. For example some_name
is snake case, but some_Name
is not.
The process for converting a string to snake case is the same as the one for kebab case, where the hyphens are replaced with underscores.
\\nconst toSnakeCase = str =>\\n str &&\\n str\\n .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)\\n .map(x => x.toLowerCase())\\n .join(\'_\');\\n\\ntoSnakeCase(\'camelCase\'); // \'camel_case\'\\ntoSnakeCase(\'some text\'); // \'some_text\'\\ntoSnakeCase(\'some-mixed_string With spaces_underscores-and-hyphens\');\\n// \'some_mixed_string_with_spaces_underscores_and_hyphens\'\\ntoSnakeCase(\'AllThe-small Things\'); // \'all_the_small_things\'\\ntoSnakeCase(\'IAmEditingSomeXMLAndHTML\');\\n// \'i_am_editing_some_xml_and_html\'\\n
Title case is most often used in titles or headings. Title case strings have the first letter of each word capitalized, with words separated by spaces. For example Some Name
is title case, but Some name
is not.
The conversion process is the same as Pascal case, except that the delimiter is a space instead of an empty string.
\\nconst toTitleCase = str =>\\n str\\n .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)\\n .map(x => x.slice(0, 1).toUpperCase() + x.slice(1))\\n .join(\' \');\\n\\ntoTitleCase(\'some_database_field_name\'); // \'Some Database Field Name\'\\ntoTitleCase(\'Some label that needs to be title-cased\');\\n// \'Some Label That Needs To Be Title Cased\'\\ntoTitleCase(\'some-package-name\'); // \'Some Package Name\'\\ntoTitleCase(\'some-mixed_string with spaces_underscores-and-hyphens\');\\n// \'Some Mixed String With Spaces Underscores And Hyphens\'\\n
Finally, sentence case is most often used in sentences. Sentence case strings have their first letter capitalized, with words separated by spaces. For example Some name
is sentence case, but some Name
is not.
As sentence case is pretty lenient, we need only make sure the delimiter is a space and that the first letter of the string is capitalized.
\\nconst toSentenceCase = str => {\\n const s =\\n str &&\\n str\\n .match(\\n /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g\\n )\\n .join(\' \');\\n return s.slice(0, 1).toUpperCase() + s.slice(1);\\n};\\n\\ntoSentenceCase(\'some_database_field_name\'); // \'Some database field name\'\\ntoSentenceCase(\'Some label that needs to be title-cased\');\\n// \'Some label that needs to be title cased\'\\ntoSentenceCase(\'some-package-name\'); // \'Some package name\'\\ntoSentenceCase(\'some-mixed_string with spaces_underscores-and-hyphens\');\\n// \'Some mixed string with spaces underscores and hyphens\'\\n
That was a lot of code snippets to go through and you might need more than one in your project. Let\'s see if we can combine them all into one function that expects a case as an argument and returns the converted string.
\\nconst convertCase = (str, toCase = \'camel\') => {\\n if (!str) return \'\';\\n\\n const delimiter =\\n toCase === \'snake\'\\n ? \'_\'\\n : toCase === \'kebab\'\\n ? \'-\'\\n : [\'title\', \'sentence\'].includes(toCase)\\n ? \' \'\\n : \'\';\\n\\n const transform = [\'camel\', \'pascal\'].includes(toCase)\\n ? x => x.slice(0, 1).toUpperCase() + x.slice(1).toLowerCase()\\n : [\'snake\', \'kebab\'].includes(toCase)\\n ? x => x.toLowerCase()\\n : toCase === \'title\'\\n ? x => x.slice(0, 1).toUpperCase() + x.slice(1)\\n : x => x;\\n\\n const finalTransform =\\n toCase === \'camel\'\\n ? x => x.slice(0, 1).toLowerCase() + x.slice(1)\\n : toCase === \'sentence\'\\n ? x => x.slice(0, 1).toUpperCase() + x.slice(1)\\n : x => x;\\n\\n const words = str.match(\\n /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g\\n );\\n\\n return finalTransform(words.map(transform).join(delimiter));\\n};\\n\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'camel\');\\n// \'mixedStringWithSpacesUnderscoresAndHyphens\'\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'pascal\');\\n// \'MixedStringWithSpacesUnderscoresAndHyphens\'\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'kebab\');\\n// \'mixed-string-with-spaces-underscores-and-hyphens\'\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'snake\');\\n// \'mixed_string_with_spaces_underscores_and_hyphens\'\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'title\');\\n// \'Mixed String With Spaces Underscores And Hyphens\'\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'sentence\');\\n// \'Mixed string with spaces underscores and hyphens\'\\n
While this code certainly is complex, it builds on top of all previous snippets, conditionally applying the appropriate transformations based on the desired case. As many of the snippets share similarities, the conditions are condensed as much as possible to avoid repetition.
Last updated: December 31, 2023 View on GitHub
","description":"Different programming languages and frameworks have different conventions for naming variables, functions and classes. It\'s often necessary to convert strings between different cases, which is where this guide comes in.\\n\\nWord boundary identification\\n\\nBefore we can convert a string…","guid":"https://www.30secondsofcode.org/js/s/string-case-conversion","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2023-12-30T16:00:00.001Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/chubby-squirrel-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/chubby-squirrel-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," String "],"attachments":null,"extra":null,"language":null},{"title":"How can I create a circular progress bar using only CSS?","url":"https://www.30secondsofcode.org/css/s/circular-progress-bar","content":"Circular progress bars are a fairly common element in today\'s websites. Yet, to many developers, they seem like quite the intimidating challenge. The truth of the matter is that getting the basics right is not that hard. In fact, with the help of some new CSS features, it\'s easier than ever.
\\nA circular progress bar is, at the simplest, two circles stacked on top of each other. The bottom circle is the background, and the top circle is the progress indicator. We\'ll get to how we fill the progress indicator in a bit, but the basic structure can be easily built using an <svg>
element and a little bit of CSS.
<svg width=\\"250\\" height=\\"250\\" viewBox=\\"0 0 250 250\\">\\n <circle class=\\"bg\\"\\n cx=\\"125\\" cy=\\"125\\" r=\\"115\\" fill=\\"none\\" stroke=\\"#ddd\\" stroke-width=\\"20\\"\\n ></circle>\\n <circle class=\\"fg\\"\\n cx=\\"125\\" cy=\\"125\\" r=\\"115\\" fill=\\"none\\" stroke=\\"#5394fd\\" stroke-width=\\"20\\"\\n ></circle>\\n</svg>\\n
circle.fg {\\n transform: rotate(-90deg);\\n transform-origin: 125px 125px;\\n}\\n
As you can see, the only piece of CSS we need to get the basic structure right is a transform
property. We rotate the foreground circle by 90 degrees, and we set the transform-origin
to the center of the circle. This way, the circle is rotated around its center, and the progress indicator starts at the top.
Before we move forward, we might as well take a moment to understand the math behind the code.
\\nThe two values we need to decide are the size of the progress bar and the width of the stroke. For this example, we settled on a size of 250px
and a stroke width of 20px
. We\'ll use these values to calculate the rest of the values we need.
The size is use to set the width
and height
attributes of the SVG element, as well as the viewBox
attribute. Dividing it by two, we get 125px
, which corresponds to the coordinates of the center of the image. This value is used to set the cx
and cy
attributes of the circles.
Taking into account the stroke width, we can calculate the radius of the circle. The radius is the distance from the center of the circle to the edge. In this case, the radius is 115px
, which is the size of the image, minus the stroke width, divided by two.
Finally, we can calculate the circumference of the circle. The circumference is the length of the edge of the circle. In this case, the circumference is 722.5px
, which is 2 * π * 115px
.
Variable | \\nValue | \\nFormula | \\n
---|---|---|
size | \\n250px | \\nN/A (user defined) | \\n
stroke | \\n20px | \\nN/A (user defined) | \\n
center | \\n125px | \\nsize / 2 | \\n
radius | \\n115px | \\n(size - stroke) / 2 | \\n
circumference | \\n722.5px | \\n2 * π * radius | \\n
These numbers will start coming in handy as we move forward, but I promise this is almost all the math we\'ll need to do.
\\nNow that we have the basic structure in place, we need to fill the progress indicator. To do this, we\'ll use the stroke-dasharray
property, which takes alternating values for lengths and dashes.
To create a progress bar, we want to pass two values: the length of the filled part, and the length of the empty part. To get the filled part we multiply the progress percentage by the circumference of the circle. To get the empty part, we subtract the filled part from the circumference.
\\nSupposing we want to fill 50% of the circle, the SVG code would look like this:
\\n<svg width=\\"250\\" height=\\"250\\" viewBox=\\"0 0 250 250\\">\\n <circle class=\\"bg\\"\\n cx=\\"125\\" cy=\\"125\\" r=\\"115\\" fill=\\"none\\" stroke=\\"#ddd\\" stroke-width=\\"20\\"\\n ></circle>\\n <circle class=\\"fg\\"\\n cx=\\"125\\" cy=\\"125\\" r=\\"115\\" fill=\\"none\\" stroke=\\"#5394fd\\" stroke-width=\\"20\\"\\n stroke-dasharray=\\"361.25 361.25\\"\\n ></circle>\\n
Hardcoding the stroke-dasharray
value is not very useful. We want to be able to set the progress percentage dynamically. This is where CSS variables and the math from before come into play.
Given a --progress
variable, we can calculate the stroke-dasharray
relatively easily. Knowing we will need most of the values from before, we can set them as CSS variables, too. Even better, most of the SVG attributes we want to set can be manipulated using CSS.
Here\'s what the restructured code looks like:
\\n<svg\\n width=\\"250\\" height=\\"250\\" viewBox=\\"0 0 250 250\\"\\n class=\\"circular-progress\\" style=\\"--progress: 50\\"\\n>\\n <circle class=\\"bg\\"></circle>\\n <circle class=\\"fg\\"></circle>\\n</svg>\\n
.circular-progress {\\n --size: 250px;\\n --half-size: calc(var(--size) / 2);\\n --stroke-width: 20px;\\n --radius: calc((var(--size) - var(--stroke-width)) / 2);\\n --circumference: calc(var(--radius) * pi * 2);\\n --dash: calc((var(--progress) * var(--circumference)) / 100);\\n}\\n\\n.circular-progress circle {\\n cx: var(--half-size);\\n cy: var(--half-size);\\n r: var(--radius);\\n stroke-width: var(--stroke-width);\\n fill: none;\\n stroke-linecap: round;\\n}\\n\\n.circular-progress circle.bg {\\n stroke: #ddd;\\n}\\n\\n.circular-progress circle.fg {\\n transform: rotate(-90deg);\\n transform-origin: var(--half-size) var(--half-size);\\n stroke-dasharray: var(--dash) calc(var(--circumference) - var(--dash));\\n transition: stroke-dasharray 0.3s linear 0s;\\n stroke: #5394fd;\\n}\\n
This may look like a lot, but it\'s mostly just setting CSS variables and using them to calculate the values we need. One cool thing I want to point out is that the pi
constant is available as part of the calc()
function! I didn\'t know this until I started writing this article, and I\'m very excited about it.
At this point, if you use some JavaScript to manipulate the value of the --progress
variable, you\'ll see the progress bar fill up. The added transition
property will make the progress bar animate smoothly.
Have you ever watched an ad inside a mobile game? You know, the ones that give you a reward if you watch the whole thing? They usually have a progress bar that fills up as the ad plays. Or it empties as you watch, much like a countdown timer. Whatever flavor you might have seen, they are variations of the same concept.
\\nHow can we create a progress bar that fills up over a predetermined amount of time? We could go about it using JavaScript and Window.requestAnimationFrame()
, but that wouldn\'t be very cool. Instead, we can use the animation
property to animate the --progress
variable from 0
to 100
over a set amount of time.
@keyframes progress-animation {\\n from {\\n --progress: 0;\\n }\\n to {\\n --progress: 100;\\n }\\n}\\n
If you try hooking this up to our SVG, you\'ll notice it doesn\'t work exactly like you\'d think. This is due to the fact that the browser doesn\'t really know what to do with the --progress
variable. It doesn\'t know it\'s a number, so it doesn\'t know how to animate it.
Luckily, CSS has come up with a solution for this. The @property
rule allows us to define custom properties and tell the browser what type they are. In this case, we want to tell the browser that --progress
is a number.
@property --progress {\\n syntax: \\"<number>\\";\\n inherits: false;\\n initial-value: 0;\\n}\\n
At the time of writing (December, 2023), the @property
rule has limited browser support. Please check before using it in production.
Now that the browser knows what to do with the --progress
variable, we can hook it up to the animation.
.circular-progress {\\n animation: progress-animation 5s linear 0s 1 forwards;\\n}\\n
This will animate the --progress
variable from 0
to 100
over 5 seconds. The forwards
keyword tells the browser to keep the final value of the animation. Without it, the progress bar would reset to 0
after the animation is done. You can create the opposite effect by setting the animation-direction
property to reverse
and using backwards
instead of forwards
.
We\'ve covered a lot of ground in this article. We\'ve gone from a simple SVG element to a fully functional progress bar. We\'ve used CSS variables, math functions, and even a new CSS feature. Let\'s take a look at the final code.
\\n<svg width=\\"250\\" height=\\"250\\" viewBox=\\"0 0 250 250\\" class=\\"circular-progress\\">\\n <circle class=\\"bg\\"></circle>\\n <circle class=\\"fg\\"></circle>\\n</svg>\\n
.circular-progress {\\n --size: 250px;\\n --half-size: calc(var(--size) / 2);\\n --stroke-width: 20px;\\n --radius: calc((var(--size) - var(--stroke-width)) / 2);\\n --circumference: calc(var(--radius) * pi * 2);\\n --dash: calc((var(--progress) * var(--circumference)) / 100);\\n animation: progress-animation 5s linear 0s 1 forwards;\\n}\\n\\n.circular-progress circle {\\n cx: var(--half-size);\\n cy: var(--half-size);\\n r: var(--radius);\\n stroke-width: var(--stroke-width);\\n fill: none;\\n stroke-linecap: round;\\n}\\n\\n.circular-progress circle.bg {\\n stroke: #ddd;\\n}\\n\\n.circular-progress circle.fg {\\n transform: rotate(-90deg);\\n transform-origin: var(--half-size) var(--half-size);\\n stroke-dasharray: var(--dash) calc(var(--circumference) - var(--dash));\\n transition: stroke-dasharray 0.3s linear 0s;\\n stroke: #5394fd;\\n}\\n\\n@property --progress {\\n syntax: \\"<number>\\";\\n inherits: false;\\n initial-value: 0;\\n}\\n\\n@keyframes progress-animation {\\n from {\\n --progress: 0;\\n }\\n to {\\n --progress: 100;\\n }\\n}\\n
And here\'s a CodePen of the code in action:
\\n\\n See the embedded CodePen\\n
\\n \\nUsing modern HTML and CSS, we created a circular progress bar. This setup serves as a good starting point for you to experiment with. You can use it as is, or you can extend it to suit your needs, sprinkling in a little bit of JavaScript if you need to. You can even convert it into a Web Component or a React component for your projects.
Last updated: December 23, 2023 View on GitHub
","description":"Circular progress bars are a fairly common element in today\'s websites. Yet, to many developers, they seem like quite the intimidating challenge. The truth of the matter is that getting the basics right is not that hard. In fact, with the help of some new CSS features, it\'s…","guid":"https://www.30secondsofcode.org/css/s/circular-progress-bar","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2023-12-22T16:00:00.286Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/clouds-over-mountains-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/clouds-over-mountains-800.webp","type":"photo","width":360,"height":180}],"categories":[" CSS "," Visual "],"attachments":null,"extra":null,"language":null},{"title":"Calculate SHA-256 hash in JavaScript","url":"https://www.30secondsofcode.org/js/s/hash-sha-256","content":"The SHA-256 algorithm is a widely used hash function producing a 256-bit hash value. It is used in many security applications and protocols, including TLS and SSL, SSH, PGP, and Bitcoin.
\\nCalculating a SHA-256 hash in JavaScript is easy using native APIs, but there are some differences between the browser and Node.js. As the browser implementation is asynchronous, both of the examples provided will return a Promise
for consistency.
In the browser, you can use the SubtleCrypto API to create a hash for the given value. You first need to create a new TextEncoder
and use it to encode the given value. Then, pass its value to SubtleCrypto.digest()
to generate a digest of the given data, resulting in a Promise
.
As the promise resolves to an ArrayBuffer
, you will need to read the data using DataView.prototype.getUint32()
. Then, you need to convert the data to its hexadecimal representation using Number.prototype.toString()
. Add the data to an array using Array.prototype.push()
. Finally, use Array.prototype.join()
to combine values in the array of hexes
into a string.
const hashValue = val =>\\n crypto.subtle\\n .digest(\'SHA-256\', new TextEncoder(\'utf-8\').encode(val))\\n .then(h => {\\n let hexes = [],\\n view = new DataView(h);\\n for (let i = 0; i < view.byteLength; i += 4)\\n hexes.push((\'00000000\' + view.getUint32(i).toString(16)).slice(-8));\\n return hexes.join(\'\');\\n });\\n\\nhashValue(\\n JSON.stringify({ a: \'a\', b: [1, 2, 3, 4], foo: { c: \'bar\' } })\\n).then(console.log);\\n// \'04aa106279f5977f59f9067fa9712afc4aedc6f5862a8defc34552d8c7206393\'\\n
In Node.js, you can use the crypto
module to create a hash for the given value. You first need to create a Hash
object with the appropriate algorithm using crypto.createHash()
. Then, use hash.update()
to add the data from val
to the Hash
and hash.digest()
to calculate the digest of the data.
For consistency with the browser implementation and to prevent blocking on a long operation, we\'ll return a Promise
by wrapping it in setTimeout()
.
import { createHash } from \'crypto\';\\n\\nconst hashValue = val =>\\n new Promise(resolve =>\\n setTimeout(\\n () => resolve(createHash(\'sha256\').update(val).digest(\'hex\')),\\n 0\\n )\\n );\\n\\nhashValue(JSON.stringify({ a: \'a\', b: [1, 2, 3, 4], foo: { c: \'bar\' } })).then(\\n console.log\\n);\\n// \'04aa106279f5977f59f9067fa9712afc4aedc6f5862a8defc34552d8c7206393\'\\n
Last updated: October 7, 2023 View on GitHub
","description":"The SHA-256 algorithm is a widely used hash function producing a 256-bit hash value. It is used in many security applications and protocols, including TLS and SSL, SSH, PGP, and Bitcoin.\\n\\nCalculating a SHA-256 hash in JavaScript is easy using native APIs, but there are some…","guid":"https://www.30secondsofcode.org/js/s/hash-sha-256","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2023-10-06T16:00:00.616Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/padlocks-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/padlocks-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Browser "],"attachments":null,"extra":null,"language":null},{"title":"What is the Event Loop in JavaScript?","url":"https://www.30secondsofcode.org/js/s/event-loop-explained","content":"The Event Loop is a source of confusion for many developers, but it\'s a fundamental piece of the JavaScript engine. It\'s what allows JavaScript to be single-threaded, yet able to execute in a non-blocking fashion. To understand the Event Loop, we first need to explain a few things about the JavaScript engine, such as the Call Stack, Tasks, Microtasks and their respective Queues. Let\'s break them down one by one.
\\nThe Call Stack is a data structure that keeps track of the execution of JavaScript code. As the name suggests, it\'s a stack, thus a LIFO (Last In, First Out) data structure in memory. Each function that\'s executed is represented as a frame in the Call Stack and placed on top of the previous function.
\\nLet\'s look at a simple example, step by step:
\\nfunction foo() {\\n console.log(\'foo\');\\n bar();\\n}\\n\\nfunction bar() {\\n console.log(\'bar\');\\n}\\n
foo()
is pushed onto the Call Stack.foo()
is executed and popped off the Call Stack.console.log(\'foo\')
is pushed onto the Call Stack.console.log(\'foo\')
is executed and popped off the Call Stack.bar()
is pushed onto the Call Stack.bar()
is executed and popped off the Call Stack.console.log(\'bar\')
is pushed onto the Call Stack.console.log(\'bar\')
is executed and popped off the Call Stack.Tasks are scheduled, synchronous blocks of code. While executing, they have exclusive access to the Call Stack and can also enqueue other tasks. Between Tasks, the browser can perform rendering updates. Tasks are stored in the Task Queue, waiting to be executed by their associated functions. The Task Queue, in turn, is a FIFO (First In, First Out) data structure. Examples of Tasks include the callback function of an event listener associated with an event and the callback of setTimeout()
.
Microtasks are similar to Tasks in that they\'re scheduled, synchronous blocks of code with exclusive access to the Call Stack while executing. Additionally, they are stored in their own FIFO (First In, First Out) data structure, the Microtask Queue. Microtasks differ from Tasks, however, in that the Microtask Queue must be emptied out after a Task completes and before re-rendering. Examples of Microtasks include Promise
callbacks and MutationObserver
callbacks.
Microtasks and the Microtask Queue are also referred to as Jobs and the Job Queue.
\\nFinally, the Event Loop is a loop that keeps running and checks if the Call Stack is empty. It processes Tasks and Microtasks, by placing them in the Call Stack one at a time and also controls the rendering process. It\'s made up of four key steps:
\\nTo better understand the Event Loop, let\'s look at a practical example, incorporating all of the above concepts:
\\nconsole.log(\'Script start\');\\n\\nsetTimeout(() => console.log(\'setTimeout()\'), 0);\\n\\nPromise.resolve()\\n .then(() => console.log(\'Promise.then() #1\'))\\n .then(() => console.log(\'Promise.then() #2\'));\\n\\nconsole.log(\'Script end\');\\n\\n// LOGS:\\n// Script start\\n// Script end\\n// Promise.then() #1\\n// Promise.then() #2\\n// setTimeout()\\n
Does the output look like what you expected? Let\'s break down what\'s happening, step by step:
\\nconsole.log()
is pushed to the Call Stack and executed, logging \'Script start\'
.setTimeout()
is pushed to the Call Stack and executed. This creates a new Task for its callback function in the Task Queue.Promise.prototype.resolve()
is pushed to the Call Stack and executed, calling in turn Promise.prototype.then()
.Promise.prototype.then()
is pushed to the Call Stack and executed. This creates a new Microtask for its callback function in the Microtask Queue.console.log()
is pushed to the Call Stack and executed, logging \'Script end\'
.Promise.prototype.then()
that was queued in step 5.console.log()
is pushed to the Call Stack and executed, logging \'Promise.then() #1\'
.Promise.prototype.then()
is pushed to the Call Stack and executed. This creates a new entry for its callback function in the Microtask Queue.Promise.prototype.then()
that was queued in step 9.console.log()
is pushed to the Call Stack and executed, logging \'Promise.then() #2\'
.setTimeout()
that was queued in step 3.console.log()
is pushed to the Call Stack and executed, logging \'setTimeout()\'
.setTimeout()
indicates a minimum time until execution, not a guaranteed time. This is due to the fact that Tasks execute in order and that Microtasks may be executed in-between.Last updated: August 21, 2022 View on GitHub
","description":"The Event Loop is a source of confusion for many developers, but it\'s a fundamental piece of the JavaScript engine. It\'s what allows JavaScript to be single-threaded, yet able to execute in a non-blocking fashion. To understand the Event Loop, we first need to explain a few…","guid":"https://www.30secondsofcode.org/js/s/event-loop-explained","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2022-08-20T16:00:00.567Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/tranquility-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/tranquility-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Browser "],"attachments":null,"extra":null,"language":null},{"title":"Can I use an object as an array without modifying it in JavaScript?","url":"https://www.30secondsofcode.org/js/s/object-array-proxy","content":"The other day, I stumbled upon some code where I needed to handle an object as a regular array a few times. This was, of course, achievable using Object.keys()
, Object.values()
or Object.entries()
, but it started getting verbose real quick.
So I thought I could create some kind of wrapper that would take an object and define some array-like behavior for it. I was mainly in need of Array.prototype.map()
, Array.prototype.find()
, Array.prototype.includes()
and Array.prototype.length
. All of this functionality was pretty straightforward to create using Object
methods. The only tricky part, so to speak, was getting the object to behave as an iterable, which required using the Symbol.iterator
and a generator function.
Injecting the new functionality into an object could be as simple as adding the methods to it. The downside of this approach is that they would be part of the actual object, which can be problematic. It also doesn\'t help that this is not very reusable if we want to apply this over a handful of objects.
\\nEnter the Proxy object, one of the lesser known tools in a JavaScript developer\'s tool belt, yet a very powerful one. It\'s used to intercept certain operations for an object, such as property lookup, assignment etc. In this scenario, it can neatly wrap the required functionality into a function that creates a proxy around the object.
\\nThe final code, long as it may be, can be seen in the example below. It implements the functionality I needed, as well as a handful more Array
methods for good measure:
const toKeyedArray = obj => {\\n const methods = {\\n map(target) {\\n return callback =>\\n Object.keys(target).map(key => callback(target[key], key, target));\\n },\\n reduce(target) {\\n return (callback, accumulator) =>\\n Object.keys(target).reduce(\\n (acc, key) => callback(acc, target[key], key, target),\\n accumulator\\n );\\n },\\n forEach(target) {\\n return callback =>\\n Object.keys(target).forEach(key => callback(target[key], key, target));\\n },\\n filter(target) {\\n return callback =>\\n Object.keys(target).reduce((acc, key) => {\\n if (callback(target[key], key, target)) acc[key] = target[key];\\n return acc;\\n }, {});\\n },\\n slice(target) {\\n return (start, end) => Object.values(target).slice(start, end);\\n },\\n find(target) {\\n return callback => {\\n return (Object.entries(target).find(([key, value]) =>\\n callback(value, key, target)\\n ) || [])[0];\\n };\\n },\\n findKey(target) {\\n return callback =>\\n Object.keys(target).find(key => callback(target[key], key, target));\\n },\\n includes(target) {\\n return val => Object.values(target).includes(val);\\n },\\n keyOf(target) {\\n return value =>\\n Object.keys(target).find(key => target[key] === value) || null;\\n },\\n lastKeyOf(target) {\\n return value =>\\n Object.keys(target)\\n .reverse()\\n .find(key => target[key] === value) || null;\\n },\\n };\\n const methodKeys = Object.keys(methods);\\n\\n const handler = {\\n get(target, prop, receiver) {\\n if (methodKeys.includes(prop)) return methods[prop](...arguments);\\n const [keys, values] = [Object.keys(target), Object.values(target)];\\n if (prop === \'length\') return keys.length;\\n if (prop === \'keys\') return keys;\\n if (prop === \'values\') return values;\\n if (prop === Symbol.iterator)\\n return function* () {\\n for (value of values) yield value;\\n return;\\n };\\n else return Reflect.get(...arguments);\\n },\\n };\\n\\n return new Proxy(obj, handler);\\n};\\n\\n// Object creation\\nconst x = toKeyedArray({ a: \'A\', b: \'B\' });\\n\\n// Accessing properties and values\\nx.a; // \'A\'\\nx.keys; // [\'a\', \'b\']\\nx.values; // [\'A\', \'B\']\\n[...x]; // [\'A\', \'B\']\\nx.length; // 2\\n\\n// Inserting values\\nx.c = \'c\'; // x = { a: \'A\', b: \'B\', c: \'c\' }\\nx.length; // 3\\n\\n// Array methods\\nx.forEach((v, i) => console.log(`${i}: ${v}`)); // LOGS: \'a: A\', \'b: B\', \'c: c\'\\nx.map((v, i) => i + v); // [\'aA\', \'bB, \'cc]\\nx.filter((v, i) => v !== \'B\'); // { a: \'A\', c: \'c\' }\\nx.reduce((a, v, i) => ({ ...a, [v]: i }), {}); // { A: \'a\', B: \'b\', c: \'c\' }\\nx.slice(0, 2); // [\'A\', \'B\']\\nx.slice(-1); // [\'c\']\\nx.find((v, i) => v === i); // \'c\'\\nx.findKey((v, i) => v === \'B\'); // \'b\'\\nx.includes(\'c\'); // true\\nx.includes(\'d\'); // false\\nx.keyOf(\'B\'); // \'b\'\\nx.keyOf(\'a\'); // null\\nx.lastKeyOf(\'c\'); // \'c\'
Last updated: September 27, 2021 View on GitHub
","description":"The other day, I stumbled upon some code where I needed to handle an object as a regular array a few times. This was, of course, achievable using Object.keys(), Object.values() or Object.entries(), but it started getting verbose real quick.\\n\\nSo I thought I could create some kind…","guid":"https://www.30secondsofcode.org/js/s/object-array-proxy","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2021-09-26T16:00:00.116Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/birds-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/birds-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"JavaScript Data Structures - Graph","url":"https://www.30secondsofcode.org/js/s/data-structures-graph","content":"A graph is a data structure consisting of a set of nodes or vertices and a set of edges that represent connections between those nodes. Graphs can be directed or undirected, while their edges can be assigned numeric weights.
\\nEach node in a graph data structure must have the following properties:
\\nkey
: The key of the nodevalue
: The value of the nodeEach edge in a graph data structure must have the following properties:
\\na
: The starting node of the edgeb
: The target node of the edgeweight
: An optional numeric weight value for the edgeThe main operations of a graph data structure are:
\\naddNode
: Inserts a new node with the specific key and valueaddEdge
: Inserts a new edge between two given nodes, optionally setting its weightremoveNode
: Removes the node with the specified keyremoveEdge
: Removes the edge between two given nodesfindNode
: Retrieves the node with the given keyhasEdge
: Checks if the graph has an edge between two given nodessetEdgeWeight
: Sets the weight of a given edgegetEdgeWeight
: Gets the weight of a given edgeadjacent
: Finds all nodes for which an edge exists from a given nodeindegree
: Calculates the total number of edges to a given nodeoutdegree
: Calculates the total number of edges from a given nodeclass Graph {\\n constructor(directed = true) {\\n this.directed = directed;\\n this.nodes = [];\\n this.edges = new Map();\\n }\\n\\n addNode(key, value = key) {\\n this.nodes.push({ key, value });\\n }\\n\\n addEdge(a, b, weight) {\\n this.edges.set(JSON.stringify([a, b]), { a, b, weight });\\n if (!this.directed)\\n this.edges.set(JSON.stringify([b, a]), { a: b, b: a, weight });\\n }\\n\\n removeNode(key) {\\n this.nodes = this.nodes.filter(n => n.key !== key);\\n [...this.edges.values()].forEach(({ a, b }) => {\\n if (a === key || b === key) this.edges.delete(JSON.stringify([a, b]));\\n });\\n }\\n\\n removeEdge(a, b) {\\n this.edges.delete(JSON.stringify([a, b]));\\n if (!this.directed) this.edges.delete(JSON.stringify([b, a]));\\n }\\n\\n findNode(key) {\\n return this.nodes.find(x => x.key === key);\\n }\\n\\n hasEdge(a, b) {\\n return this.edges.has(JSON.stringify([a, b]));\\n }\\n\\n setEdgeWeight(a, b, weight) {\\n this.edges.set(JSON.stringify([a, b]), { a, b, weight });\\n if (!this.directed)\\n this.edges.set(JSON.stringify([b, a]), { a: b, b: a, weight });\\n }\\n\\n getEdgeWeight(a, b) {\\n return this.edges.get(JSON.stringify([a, b])).weight;\\n }\\n\\n adjacent(key) {\\n return [...this.edges.values()].reduce((acc, { a, b }) => {\\n if (a === key) acc.push(b);\\n return acc;\\n }, []);\\n }\\n\\n indegree(key) {\\n return [...this.edges.values()].reduce((acc, { a, b }) => {\\n if (b === key) acc++;\\n return acc;\\n }, 0);\\n }\\n\\n outdegree(key) {\\n return [...this.edges.values()].reduce((acc, { a, b }) => {\\n if (a === key) acc++;\\n return acc;\\n }, 0);\\n }\\n}\\n
Create a class
with a constructor
that initializes an empty array, nodes
, and a Map
, edges
, for each instance. The optional argument, directed
, specifies if the graph is directed or not.
Define an addNode()
method, which uses Array.prototype.push()
to add a new node in the nodes
array.
Define an addEdge()
method, which uses Map.prototype.set()
to add a new edge to the edges
Map, using JSON.stringify()
to produce a unique key.
Define a removeNode()
method, which uses Array.prototype.filter()
and Map.prototype.delete()
to remove the given node and any edges connected to it.
Define a removeEdge()
method, which uses Map.prototype.delete()
to remove the given edge.
Define a findNode()
method, which uses Array.prototype.find()
to return the given node, if any.
Define a hasEdge()
method, which uses Map.prototype.has()
and JSON.stringify()
to check if the given edge exists in the edges
Map.
Define a setEdgeWeight()
method, which uses Map.prototype.set()
to set the weight of the appropriate edge, whose key is produced by JSON.stringify()
.
Define a getEdgeWeight()
method, which uses Map.prototype.get()
to get the eight of the appropriate edge, whose key is produced by JSON.stringify()
.
Define an adjacent()
method, which uses Map.prototype.values()
, Array.prototype.reduce()
and Array.prototype.push()
to find all nodes connected to the given node.
Define an indegree()
method, which uses Map.prototype.values()
and Array.prototype.reduce()
to count the number of edges to the given node.
Define an outdegree()
method, which uses Map.prototype.values()
and Array.prototype.reduce()
to count the number of edges from the given node.
const g = new Graph();\\n\\ng.addNode(\'a\');\\ng.addNode(\'b\');\\ng.addNode(\'c\');\\ng.addNode(\'d\');\\n\\ng.addEdge(\'a\', \'c\');\\ng.addEdge(\'b\', \'c\');\\ng.addEdge(\'c\', \'b\');\\ng.addEdge(\'d\', \'a\');\\n\\ng.nodes.map(x => x.value); // [\'a\', \'b\', \'c\', \'d\']\\n[...g.edges.values()].map(({ a, b }) => `${a} => ${b}`);\\n// [\'a => c\', \'b => c\', \'c => b\', \'d => a\']\\n\\ng.adjacent(\'c\'); // [\'b\']\\n\\ng.indegree(\'c\'); // 2\\ng.outdegree(\'c\'); // 1\\n\\ng.hasEdge(\'d\', \'a\'); // true\\ng.hasEdge(\'a\', \'d\'); // false\\n\\ng.removeEdge(\'c\', \'b\');\\n\\n[...g.edges.values()].map(({ a, b }) => `${a} => ${b}`);\\n// [\'a => c\', \'b => c\', \'d => a\']\\n\\ng.removeNode(\'c\');\\n\\ng.nodes.map(x => x.value); // [\'a\', \'b\', \'d\']\\n[...g.edges.values()].map(({ a, b }) => `${a} => ${b}`);\\n// [\'d => a\']\\n\\ng.setEdgeWeight(\'d\', \'a\', 5);\\ng.getEdgeWeight(\'d\', \'a\'); // 5
Last updated: August 17, 2021 View on GitHub
","description":"Definition\\n\\nA graph is a data structure consisting of a set of nodes or vertices and a set of edges that represent connections between those nodes. Graphs can be directed or undirected, while their edges can be assigned numeric weights.\\n\\nEach node in a graph data structure must…","guid":"https://www.30secondsofcode.org/js/s/data-structures-graph","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2021-08-16T16:00:00.870Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/purple-flower-macro-1-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/purple-flower-macro-1-800.webp","type":"photo","width":360,"height":180},{"url":"https://www.30secondsofcode.org/assets/illustrations/ds-graph.svg","type":"photo","width":800,"height":316,"blurhash":"L12i67-:Dzxut7ofWBaxakIUD#og"}],"categories":[" JavaScript "," Data Structures "],"attachments":null,"extra":null,"language":null},{"title":"JavaScript Data Structures - Doubly Linked List","url":"https://www.30secondsofcode.org/js/s/data-structures-doubly-linked-list","content":"A doubly linked list is a linear data structure that represents a collection of elements, where each element points both to the next and the previous one. The first element in the doubly linked list is the head and the last element is the tail.
\\nEach element of a doubly linked list data structure must have the following properties:
\\nvalue
: The value of the elementnext
: A pointer to the next element in the linked list (null
if there is none)previous
: A pointer to the previous element in the linked list (null
if there is none)The main properties of a doubly linked list data structure are:
\\nsize
: The number of elements in the doubly linked listhead
: The first element in the doubly linked listtail
: The last element in the doubly linked listThe main operations of a doubly linked list data structure are:
\\ninsertAt
: Inserts an element at the specific indexremoveAt
: Removes the element at the specific indexgetAt
: Retrieves the element at the specific indexclear
: Empties the doubly linked listreverse
: Reverses the order of elements in the doubly linked listclass DoublyLinkedList {\\n constructor() {\\n this.nodes = [];\\n }\\n\\n get size() {\\n return this.nodes.length;\\n }\\n\\n get head() {\\n return this.size ? this.nodes[0] : null;\\n }\\n\\n get tail() {\\n return this.size ? this.nodes[this.size - 1] : null;\\n }\\n\\n insertAt(index, value) {\\n const previousNode = this.nodes[index - 1] || null;\\n const nextNode = this.nodes[index] || null;\\n const node = { value, next: nextNode, previous: previousNode };\\n\\n if (previousNode) previousNode.next = node;\\n if (nextNode) nextNode.previous = node;\\n this.nodes.splice(index, 0, node);\\n }\\n\\n insertFirst(value) {\\n this.insertAt(0, value);\\n }\\n\\n insertLast(value) {\\n this.insertAt(this.size, value);\\n }\\n\\n getAt(index) {\\n return this.nodes[index];\\n }\\n\\n removeAt(index) {\\n const previousNode = this.nodes[index - 1] || null;\\n const nextNode = this.nodes[index + 1] || null;\\n\\n if (previousNode) previousNode.next = nextNode;\\n if (nextNode) nextNode.previous = previousNode;\\n\\n return this.nodes.splice(index, 1);\\n }\\n\\n clear() {\\n this.nodes = [];\\n }\\n\\n reverse() {\\n this.nodes = this.nodes.reduce((acc, { value }) => {\\n const nextNode = acc[0] || null;\\n const node = { value, next: nextNode, previous: null };\\n if (nextNode) nextNode.previous = node;\\n return [node, ...acc];\\n }, []);\\n }\\n\\n *[Symbol.iterator]() {\\n yield* this.nodes;\\n }\\n}\\n
class
with a constructor
that initializes an empty array, nodes
, for each instance.size
getter, that returns that uses Array.prototype.length
to return the number of elements in the nodes
array.head
getter, that returns the first element of the nodes
array or null
if empty.tail
getter, that returns the last element of the nodes
array or null
if empty.insertAt()
method, which uses Array.prototype.splice()
to add a new object in the nodes
array, updating the next
and previous
keys of the previous and next elements respectively.insertFirst()
and insertLast()
that use the insertAt()
method to insert a new element at the start or end of the nodes
array respectively.getAt()
method, which retrieves the element in the given index
.removeAt()
method, which uses Array.prototype.splice()
to remove an object in the nodes
array, updating the next
and previous
keys of the previous and next elements respectively.clear()
method, which empties the nodes
array.reverse()
method, which uses Array.prototype.reduce()
and the spread operator (...
) to reverse the order of the nodes
array, updating the next
and previous
keys of each element appropriately.Symbol.iterator
, which delegates to the nodes
array\'s iterator using the yield*
syntax.const list = new DoublyLinkedList();\\n\\nlist.insertFirst(1);\\nlist.insertFirst(2);\\nlist.insertFirst(3);\\nlist.insertLast(4);\\nlist.insertAt(3, 5);\\n\\nlist.size; // 5\\nlist.head.value; // 3\\nlist.head.next.value; // 2\\nlist.tail.value; // 4\\nlist.tail.previous.value; // 5\\n[...list.map(e => e.value)]; // [3, 2, 1, 5, 4]\\n\\nlist.removeAt(1); // 2\\nlist.getAt(1).value; // 1\\nlist.head.next.value; // 1\\n[...list.map(e => e.value)]; // [3, 1, 5, 4]\\n\\nlist.reverse();\\n[...list.map(e => e.value)]; // [4, 5, 1, 3]\\n\\nlist.clear();\\nlist.size; // 0
Last updated: August 12, 2021 View on GitHub
","description":"Definition\\n\\nA doubly linked list is a linear data structure that represents a collection of elements, where each element points both to the next and the previous one. The first element in the doubly linked list is the head and the last element is the tail.\\n\\nEach element of a…","guid":"https://www.30secondsofcode.org/js/s/data-structures-doubly-linked-list","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2021-08-11T16:00:00.902Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/purple-flower-macro-4-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/purple-flower-macro-4-800.webp","type":"photo","width":360,"height":180},{"url":"https://www.30secondsofcode.org/assets/illustrations/ds-doubly-linked-list.svg","type":"photo","width":800,"height":133,"blurhash":"L54.9|WA4,%NM_j]ogWA4mof%OM{"}],"categories":[" JavaScript "," Data Structures "],"attachments":null,"extra":null,"language":null},{"title":"JavaScript Data Structures - Linked List","url":"https://www.30secondsofcode.org/js/s/data-structures-linked-list","content":"A linked list is a linear data structure that represents a collection of elements, where each element points to the next one. The first element in the linked list is the head and the last element is the tail.
\\nEach element of a linked list data structure must have the following properties:
\\nvalue
: The value of the elementnext
: A pointer to the next element in the linked list (null
if there is none)The main properties of a linked list data structure are:
\\nsize
: The number of elements in the linked listhead
: The first element in the linked listtail
: The last element in the linked listThe main operations of a linked list data structure are:
\\ninsertAt
: Inserts an element at the specific indexremoveAt
: Removes the element at the specific indexgetAt
: Retrieves the element at the specific indexclear
: Empties the linked listreverse
: Reverses the order of elements in the linked listclass LinkedList {\\n constructor() {\\n this.nodes = [];\\n }\\n\\n get size() {\\n return this.nodes.length;\\n }\\n\\n get head() {\\n return this.size ? this.nodes[0] : null;\\n }\\n\\n get tail() {\\n return this.size ? this.nodes[this.size - 1] : null;\\n }\\n\\n insertAt(index, value) {\\n const previousNode = this.nodes[index - 1] || null;\\n const nextNode = this.nodes[index] || null;\\n const node = { value, next: nextNode };\\n\\n if (previousNode) previousNode.next = node;\\n this.nodes.splice(index, 0, node);\\n }\\n\\n insertFirst(value) {\\n this.insertAt(0, value);\\n }\\n\\n insertLast(value) {\\n this.insertAt(this.size, value);\\n }\\n\\n getAt(index) {\\n return this.nodes[index];\\n }\\n\\n removeAt(index) {\\n const previousNode = this.nodes[index - 1];\\n const nextNode = this.nodes[index + 1] || null;\\n\\n if (previousNode) previousNode.next = nextNode;\\n\\n return this.nodes.splice(index, 1);\\n }\\n\\n clear() {\\n this.nodes = [];\\n }\\n\\n reverse() {\\n this.nodes = this.nodes.reduce(\\n (acc, { value }) => [{ value, next: acc[0] || null }, ...acc],\\n []\\n );\\n }\\n\\n *[Symbol.iterator]() {\\n yield* this.nodes;\\n }\\n}\\n
class
with a constructor
that initializes an empty array, nodes
, for each instance.size
getter, that returns that uses Array.prototype.length
to return the number of elements in the nodes
array.head
getter, that returns the first element of the nodes
array or null
if empty.tail
getter, that returns the last element of the nodes
array or null
if empty.insertAt()
method, which uses Array.prototype.splice()
to add a new object in the nodes
array, updating the next
key of the previous element.insertFirst()
and insertLast()
that use the insertAt()
method to insert a new element at the start or end of the nodes
array respectively.getAt()
method, which retrieves the element in the given index
.removeAt()
method, which uses Array.prototype.splice()
to remove an object in the nodes
array, updating the next
key of the previous element.clear()
method, which empties the nodes
array.reverse()
method, which uses Array.prototype.reduce()
and the spread operator (...
) to reverse the order of the nodes
array, updating the next
key of each element appropriately.Symbol.iterator
, which delegates to the nodes
array\'s iterator using the yield*
syntax.const list = new LinkedList();\\n\\nlist.insertFirst(1);\\nlist.insertFirst(2);\\nlist.insertFirst(3);\\nlist.insertLast(4);\\nlist.insertAt(3, 5);\\n\\nlist.size; // 5\\nlist.head.value; // 3\\nlist.head.next.value; // 2\\nlist.tail.value; // 4\\n[...list.map(e => e.value)]; // [3, 2, 1, 5, 4]\\n\\nlist.removeAt(1); // 2\\nlist.getAt(1).value; // 1\\nlist.head.next.value; // 1\\n[...list.map(e => e.value)]; // [3, 1, 5, 4]\\n\\nlist.reverse();\\n[...list.map(e => e.value)]; // [4, 5, 1, 3]\\n\\nlist.clear();\\nlist.size; // 0
Last updated: August 8, 2021 View on GitHub
","description":"Definition\\n\\nA linked list is a linear data structure that represents a collection of elements, where each element points to the next one. The first element in the linked list is the head and the last element is the tail.\\n\\nEach element of a linked list data structure must have the…","guid":"https://www.30secondsofcode.org/js/s/data-structures-linked-list","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2021-08-07T16:00:00.137Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/purple-flower-macro-3-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/purple-flower-macro-3-800.webp","type":"photo","width":360,"height":180},{"url":"https://www.30secondsofcode.org/assets/illustrations/ds-linked-list.svg","type":"photo","width":800,"height":133,"blurhash":"L655IRI9M{%NM_j^ofax4mxvt7Ri"}],"categories":[" JavaScript "," Data Structures "],"attachments":null,"extra":null,"language":null}],"readCount":153,"subscriptionCount":10,"analytics":{"feedId":"99015134311696384","updatesPerWeek":2,"subscriptionCount":10,"latestEntryPublishedAt":null,"view":0}}')