When building frontend applications, it's common to update the User Interface (UI) in response to data changes. However, directly manipulating the UI within every event handler or function that changes data can lead to bloated and hard-to-maintain code. A better approach is to define states and have the UI automatically update when these states change.
In this article, we'll explore how to manage UI changes using states by examining a practical example from a project built with StimulusJS and Tailwind CSS. The project is a calculator designed to help solve the research office puzzle from the game Call of Duty: Black Ops 6. Users select symbols corresponding to variables X, Y, and Z, and the app calculates and displays the resulting codes.
You can checkout the live demo of the project at Terminus Calculator.
The Problem with Direct UI Manipulation
Often, developers may be tempted to manipulate the UI directly whenever data changes. For instance, consider the following function:
function selectSymbol(event) {
// ... data processing logic
// Directly manipulate the UI
event.target.classList.add("selected");
// Update results display
document.getElementById("code1").textContent = calculatedCode1;
document.getElementById("code2").textContent = calculatedCode2;
document.getElementById("code3").textContent = calculatedCode3;
}
While this works for simple cases, it can quickly become unmanageable as the application grows. Mixing data logic and UI manipulation makes the code harder to read, test, and maintain.
Using State to Manage UI Updates
By defining states, we can separate data logic from UI presentation. This approach leverages reactive programming principles, where the UI automatically reflects the current state of the application.
Implementing State with StimulusJS
In our project, we use StimulusJS, a modest JavaScript framework that enhances static HTML by connecting elements to JavaScript classes. Stimulus encourages organizing code around controllers, targets, and values.
Here's how we define our state using a selectedSymbol
value:
<script type="module">
import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
window.Stimulus = Application.start();
Stimulus.register("math-puzzle-calculator", class extends Controller {
static targets = ["result", "resultInstructions", "code1", "code2", "code3"];
static values = {
selectedSymbol: {
type: Object,
default: {
x: "",
y: "",
z: "",
},
},
};
// ...
});
</script>
By declaring selectedSymbol
as a value, Stimulus automatically observes changes to it. We can then define a callback that executes whenever selectedSymbol
changes:
Stimulus.register("math-puzzle-calculator", class extends Controller {
// ...
selectedSymbolValueChanged(value) {
this.updateSymbolStates();
if (Object.values(this.selectedSymbolValue).every(symbol => symbol !== "")) {
this.calculate();
} else {
this.resetResult();
}
}
// ...
});
Updating the UI When State Changes
In selectedSymbolValueChanged
, we call updateSymbolStates
to reflect the current selection in the UI:
updateSymbolStates() {
// Remove all selected classes
this.element.querySelectorAll(".selected").forEach(element => {
element.classList.remove("selected");
});
// Add selected class to currently selected symbols
Object.entries(this.selectedSymbolValue).forEach(([variable, symbol]) => {
const symbolElement = this.element.querySelector(`[data-variable="${variable}"][data-symbol="${symbol}"]`);
if (symbolElement) {
symbolElement.classList.add("selected");
}
});
}
By centralizing UI updates in response to state changes, we keep our code organized and maintainable.
Handling User Interaction
When a user selects a symbol, we update the state rather than manipulating the UI directly:
selectSymbol(event) {
const symbolElement = event.target.closest("[data-variable][data-symbol]");
if (!symbolElement) {
return;
}
const variable = symbolElement.dataset.variable;
const symbol = symbolElement.dataset.symbol;
// Update state
this.selectedSymbolValue = {
...this.selectedSymbolValue,
[variable]: this.selectedSymbolValue[variable] === symbol ? "" : symbol,
};
}
Here, we check if the symbol is already selected; if so, we deselect it by setting it to an empty string. We then assign the new state to selectedSymbolValue
, triggering the selectedSymbolValueChanged
callback.
Calculating and Displaying Results
When all symbols are selected, we perform calculations and update the results:
calculate() {
const x = this.SYMBOLS[this.selectedSymbolValue.x];
const y = this.SYMBOLS[this.selectedSymbolValue.y];
const z = this.SYMBOLS[this.selectedSymbolValue.z];
const code1 = (2 * x) + 11;
const code2 = ((2 * z) + y) - 5;
const code3 = Math.abs((y + z) - x);
this.updateResult(code1, code2, code3);
}
updateResult(code1, code2, code3) {
if (isNaN(code1) || isNaN(code2) || isNaN(code3)) {
this.resetResult();
return;
}
this.code1Target.textContent = code1;
this.code2Target.textContent = code2;
this.code3Target.textContent = code3;
this.showResult();
}
Again, UI updates are contained within updateResult
, which is called whenever calculations are performed.
Benefits of Using State Management
By using state to manage UI changes, we achieve several benefits:
- Separation of Concerns: Data logic and UI updates are separated, making the code easier to understand and maintain.
- Reactivity: The UI automatically reflects the current state, reducing the possibility of inconsistencies.
- Scalability: As the application grows, adding new states and UI updates becomes more manageable.
Conclusion
Managing UI changes through state management leads to cleaner, more maintainable code. By leveraging frameworks like StimulusJS, we can create reactive interfaces without the overhead of larger libraries. In our example, the use of selectedSymbolValue
as a state allows us to efficiently handle user interactions and UI updates, resulting in a better developer and user experience.
Top comments (0)