DEV Community

Sebastian Jones
Sebastian Jones

Posted on

Using states to manage UI changes

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }

    // ...
});
Enter fullscreen mode Exit fullscreen mode

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");
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

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,
    };
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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)