Skip to main content

Level 6: Conditionally Showing Elements

Introduction

Until now, the components we defined always returned the same sub elements, so it was not possible, for example, to return a part of a component only if a certain condition is met. In this level, we will look at the additions that are needed to allow exactly this.

Goal

Below you can see the source code for the demo of this level. The index.html file will be the same again as in levels 1-4.

index.js

import { createElement } from '../../lib/js/fiber.js'
import { useState } from '../../lib/js/hooks.js'
import { render } from '../../lib/js/render.js'
import { parseSjdon } from '../../lib/js/sjdon.js'


const App = () => {
const [show , setShow] = useState(false)

return [
'div',
[
'button',
`${ show ? 'Hide' : 'Show' } Text 1`,
{ onclick: () => setShow(!show) }
],
show && [TextField, { name: 'Text 1' }],
[TextField , { name: 'Text 2' }]
]
}

const TextField = props => {
const [text, setText] = useState(props?.initialValue ?? '')
return [
'div',
['label', `${props?.name}: `,
[
'input',
{
value: text,
oninput: event => setText(event.target.value),
},
],
],
['p', 'Value: ', ['strong', text]],
]
}

render(
parseSjdon([App], createElement),
document.getElementById('app')
)

If we render the component, we can see a button and a text field with a label "Text 2". If we click the button, a second text field with a label "Text 1" is inserted before the other text field. When we enter text into a text field, it will be shown below the text field, prefixed with the text "Value: ". Note that the value of the second text field will remain, even if the first text field is hidden and shown again. It will just change its position.

We will not look in detail at the implementation of the TextField function, as it's a quite basic functional component using state, like we have already seen in previous demos.

We will, however, look into the implementation of the App component. In there, we can see that first, a button is defined, that has a different text depending on whether show is true or false. Then, we set an action for the onclick event of the button, in which we call setShow(!show), which means that the value of show will be inverted on every button click.

In the following line, we can see some interesting syntax: show && [TextField, { name: 'Text 1' }]. This pattern is actually used quite frequently in JavaScript. If you are not yet familiar with it, it will basically just equal false in case of any value being falsy in the chain, or the last value otherwise. In other words, if we assigned this statement to a variable const value = show && [TextField, { name: 'Text 1' }], value would be false if show == false and [TextField, { name: 'Text 1' }] otherwise. Note that nothing is rendered to the DOM if the value is false, meaning the text field is only shown if show is truthy.

In the last line, another TextField is added to the component, without any conditions.

What Makes Conditional Rendering Difficult

What might feel quite natural when using, is actually a bit more complicated once you think about what is needed to make this work under the hood.

You might remember that, in order to retain the memorizedStates array of fibers between renderings, it is copied over from the previous version of that fiber. For that, we create a copy of the fiber inside rerenderFunctionalFiber and use it as the previous version. Then, we also need to go through the previous versions of all children of that fiber, which we do in expandChildFibers, as we can see in the excerpt of that function below.

const reversedChildren = Array.from(currentChildren.entries()).reverse()
reversedChildren.forEach(([key, currentChild]) => {
const previousChild = previousChildren.get(key)
renderFiber(currentChild, container, previousChild, nextChildSibling)
})

To determine the previous child which matches the currentChild, we use the key at which the child is stored inside the currentChildren Map. Remember that this key is determined automatically (if not defined explicitly) based on the order at which the child was added to its parent.

This means that in our example, the button would have the key d-0 (the d- is prefixed to prevent overlap of explicit and default keys; explicit keys will be prefixed with e-), and the first text field that is shown d-1. If there was a second text field, it would have the key d-2.

Maybe you can already see the problem which will now arise: Because the first text field is only shown under certain conditions, the key of the second text field would actually be different, depending on whether the first is shown or not (d-1 if the first text field is hidden, d-2 if the first text field is shown). With that, matching of the current and previous version would no longer work. It would actually just copy over the memorizedStates into the first text field, as it receives the d-1 key. The state of the second text field would be lost (moved to the first), because it receives the key d-2 which does not match with anything from the previous version.

But as you have seen, the problem described here does not actually occur in SuiWeb, so we will look how this is made possible in the following section.

The Idea of Placeholders

You might remember from the example in the beginning of this level, we don't render anything if an entry inside an SJDON array is false. This does not mean, however, that this element is not seen by the createElement function.

The idea behind conditional rendering is that we create a special placeholder fiber in case that an element is undefined, null or false. We will then not render this placeholder fiber to the DOM, but keep its representation like every other fiber in our fiber tree. With that, we make sure that also placeholders will get a key, thus the count of elements will always be constant, which ensures that default keys remain the same between rendering cycles, even if some components are not always shown.

Now, let's look at the actual implementation of what we just described.

Creating Placeholder Fibers

To see where those placeholder fibers are created, we need to revisit the mapChildren function, at which we already looked at partly in level 1. The line that has been added is highlighted in the source code below.

function mapChildren(childrenRaw: (Fiber | Primitive)[]): Map<string, Fiber> {
const children = childrenRaw.map(child => {
if (child == null || child === false) return createPlaceholderFiber()
else if (isPrimitive(child)) return createTextFiber(child)
else return child
})

// Use a Map to store children in, as this allows to set a custom key,
// while also guaranteeing to preserve the order of insertion (unlike object).
const childrenMap: Map<string, Fiber> = new Map()
let defaultKey = 0
children.forEach(child => {
const childProps = isFunctionalFiber(child) ? child.functionProps : child.props
// Keys are prefixed e-(xplicit) or d-(efault), so a custom key can never
// accidentally match a generated key.
const key = childProps?.key ? `e-${childProps?.key}` : `d-${defaultKey++}`
childrenMap.set(key, child)
})

return childrenMap
}

If the current child in the loop is either null, undefined or false, a placeholder fiber will be created using the createPlaceholderFiber function.

function createPlaceholderFiber(): StaticFiber {
return {
type: 'PLACEHOLDER_NODE',
props: {},
children: new Map(),
}
}

The function, that is quite similar to createTextFiber, returns an object of type StaticFiber, which has a type of PLACEHOLDER_NODE, an empty props object and an empty Map of children.

Revisiting the renderFiber function

Now that we have seen how and when placeholder fibers are created, we'll also look at the full implementation of renderFiber, to see what we do with placeholder fibers there. The parts that are added are again highlighted in the code below.

function renderFiber(fiber: Fiber, container: HTMLElement, previousVersion?: Fiber, nextSibling?: Fiber) {
// If the component is a functional fiber, execute its fiberFunction
// to get the unwrapped StaticFiber properties merged into the same object.
if (isFunctionalFiber(fiber)) unwrapFunctionalFiber(fiber, container, previousVersion)

// After unwrapping, the fiber must contain all properties of a static fiber.
if (!isStaticFiber(fiber)) throw new Error('Fiber did not contain all StaticFiber properties after unwrapping.')

// If the fiber is a placeholder, just remove the previous version, if exists.
if (fiber.type === 'PLACEHOLDER_NODE') {
fiber.domNode = undefined
previousVersion?.domNode?.remove()
return
}

// Determines if the new fiber still has the same type as the old fiber.
const areSameType = fiber && previousVersion && fiber.type === previousVersion.type

// Got a fiber with the same type in the tree, so just update the contents of the DOM node.
if (areSameType) updateFiberInDom(fiber, container, previousVersion, nextSibling)
// The types did not match, create new DOM node and remove previous DOM node.
else replaceFiberInDom(fiber, container, previousVersion, nextSibling)

expandChildFibers(fiber, previousVersion)
}

As you can see, if the fiber's type equals PLACEHOLDER_NODE, its domNode property is set to undefined and the domNode of the previousVersion will be removed, in case it exists.

This case will apply in the example shown in the beginning of this tutorial when the first text field is shown, and then removed. In the rendering phase in which the text field is removed, its type will be PLACEHOLDER_NODE, as we pass false instead of the text field. Thus, the HTML input element, that has been added to the DOM during the previous rendering phase, needs to be removed from the DOM now.

Conditional Rendering in JSX

Now that we have seen how conditional rendering works in SJDON, we will also have a brief look at how the concept works in JSX.

For that, let's look at the following example, which represents the component we've looked at in the beginning of this level, just in JSX.

import { createElement } from '../../lib/js/fiber.js'
import { useState } from '../../lib/js/hooks.js'
import { render } from '../../lib/js/render.js'

/** @jsx createElement */

const App = () => {
const [show , setShow] = useState(false)

return (
<div>
<button onclick={() => setShow(!show)}>
{`${ show ? 'Hide' : 'Show' } Text 1`}
</button>
{show && <TextField name="Text 1" />}
<TextField name="Text 2" />
</div>
)
}

const TextField = props => {
const [text, setText] = useState(props?.initialValue ?? '')
return (
<div>
<label>
{props.name}
<input
value={text}
oninput={event => setText(event.target.value)}
/>
</label>
</div>
)
}

render(
<App />,
document.getElementById('app')
)

As you can see, the syntax works quite similar to SJDON. We also use the && to either return false or the TextField component. Note that we need to wrap the statement in curly braces { ... }, as the code would otherwise not be valid JSX.

Review

With that, we have looked at everything that is needed to render (or not render) elements based on simple conditions. There might, however, be cases where it will not be enough to have simple if conditions that decide on whether an element will be rendered or not. Think of examples that render a dynamic amount of elements, that could change based on certain criteria. For that, we need a more advanced technique to identify individual components. This is what we are going to look at in the next level.