DEV Community

Omri Luz
Omri Luz

Posted on

Designing a Scalable State Container in Vanilla JavaScript

Designing a Scalable State Container in Vanilla JavaScript

Introduction

As web applications grow in complexity, managing application state becomes paramount. A scalable state container is crucial for maintaining the integrity, consistency, and performance of the app, especially when dealing with user interactions, data fetching, and global state management. While frameworks such as React, Vue, and Angular offer sophisticated solutions to state management, a well-designed state container can be constructed using plain Vanilla JavaScript. This article delves deep into the art and science of creating a scalable state container, covering its historical context, advanced implementation strategies, edge cases, and performance optimization techniques.

Historical and Technical Context

State management in JavaScript has evolved considerably. Early web applications relied upon global variables or simple objects to manage state. As applications grew, patterns such as the Flux architecture emerged, followed by the popularization of Redux, a predictable state container for JavaScript apps. Redux introduced the concept of unidirectional data flow and immutability, marking a significant shift in how developers approached state management.

In the context of Vanilla JavaScript, the need for a flexible state container has resurfaced as modern development emphasizes minimalism and performance. Many developers now prefer implementing solutions that do not rely on heavy libraries to ensure lighter and more maintainable applications.

Core Principles of a State Container

Before we dive into the implementation, it's vital to understand the core principles behind a scalable state container:

  1. Centralized State: All state is managed from a single source of truth.
  2. Immutability: State changes should not mutate the current state, but create a new state instead.
  3. Subscription Model: Allow components to subscribe and react to state changes.
  4. Middleware Support: Enable the application of additional logic between dispatching actions and the state being handled.
  5. Persistence: State should be storable and retrievable (optional but good to consider).

Basic Implementation of a State Container

Let’s kick things off with a simple implementation of a state container.

class StateContainer {
    constructor(initialState = {}) {
        this.state = initialState;
        this.listeners = [];
    }

    getState() {
        return this.state;
    }

    setState(newState) {
        this.state = { ...this.state, ...newState };
        this.listeners.forEach(listener => listener(this.state));
    }

    subscribe(listener) {
        this.listeners.push(listener);
        return () => {
            this.listeners = this.listeners.filter(l => l !== listener);
        };
    }
}

// Usage
const store = new StateContainer({ user: null });

// Subscribe to state changes
const unsubscribe = store.subscribe(state => console.log('State changed:', state));

// Update the state
store.setState({ user: { name: 'Alice' } });
store.setState({ user: { name: 'Alice', age: 25 } });

// Unsubscribe
unsubscribe();
Enter fullscreen mode Exit fullscreen mode

Advanced Implementation Techniques

The above implementation is fairly basic. To make it more robust, we can introduce more advanced techniques.

1. Immutable Updates with Immer.js

Managing immutability can become cumbersome. Libraries like Immer allow you to write simpler code while ensuring immutability.

import produce from 'immer';

class StateContainer {
    constructor(initialState = {}) {
        this.state = initialState;
        this.listeners = [];
    }

    getState() {
        return this.state;
    }

    setState(updater) {
        this.state = produce(this.state, updater);
        this.listeners.forEach(listener => listener(this.state));
    }

    subscribe(listener) {
        this.listeners.push(listener);
        return () => {
            this.listeners = this.listeners.filter(l => l !== listener);
        };
    }
}

// Usage
const store = new StateContainer({ user: null });

store.setState(draft => {
    draft.user = { name: 'Alice' };
});
Enter fullscreen mode Exit fullscreen mode

This approach not only simplifies state updates but also conforms to the principle of immutability.

2. Action Creators and Reducers

To further mimic the Flux pattern, you can implement action creators and reducers.

// Action Types
const SET_USER = 'SET_USER';

// Action Creator
const setUser = (user) => ({ type: SET_USER, payload: user });

// Reducer
const reducer = (state, action) => {
    switch(action.type) {
        case SET_USER:
            return { ...state, user: action.payload };
        default:
            return state;
    }
}

// Integrating into StateContainer
class StateContainer {
    constructor(initialState = {}) {
        this.state = initialState;
        this.listeners = [];
    }

    dispatch(action) {
        this.state = reducer(this.state, action);
        this.listeners.forEach(listener => listener(this.state));
    }

    subscribe(listener) {
        this.listeners.push(listener);
        return () => {
            this.listeners = this.listeners.filter(l => l !== listener);
        };
    }
}

// Usage
const store = new StateContainer({ user: null });
store.dispatch(setUser({ name: 'Alice' }));
Enter fullscreen mode Exit fullscreen mode

Edge Cases and Robustness

When designing a scalable state container, consider these edge cases:

  • Batching State Updates: Make sure multiple setState calls within the same event handler are batched.
  • Error Handling: Implement a way to handle errors in asynchronous operations (e.g., API calls).
  • Memory Leaks: Ensure that subscriptions are cleaned up when no longer needed.

Here’s an implementation of batching state updates:

batchUpdates = (updates) => {
    const currentState = this.getState();
    const newState = updates.reduce((state, update) => {
        return { ...state, ...update(currentState) };
    }, currentState);
    this.setState(newState);
};

// Usage
store.batchUpdates([
    (state) => ({ user: { ...state.user, age: 26 } }),
    (state) => ({ appStatus: 'updated' }),
]);
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

To create an efficient state container, consider these strategies:

  1. Memoization: Use libraries like reselect to create memoized selectors, enhancing performance by avoiding unnecessary recomputations.

  2. Throttling and Debouncing: Implement throttling/debouncing for unsubscribe and setState calls that come in succession.

Advanced Debugging Techniques

Debugging a state container can be challenging. Implement the following strategies:

  • Logging Middleware: Create middleware to log actions and state changes.
const loggingMiddleware = (store) => (action) => {
    console.log('Dispatching: ', action);
    store.dispatch(action);
};

// Integration
const store = new StateContainer();
store.middleware = loggingMiddleware(store);
Enter fullscreen mode Exit fullscreen mode
  • Redux DevTools: Consider integrating with Redux DevTools to replay actions and inspect state changes over time.

Comparison with Other Techniques

Designing a state container from scratch in Vanilla JavaScript stands in contrast to using a library:

  • Simplicity and Control: Custom implementations offer granularity and control.
  • Performance: Lightweight solutions could outperform heavier libraries if correctly optimized.
  • Learning Curve: While pre-built libraries may reduce overhead, they often come with their learning curves and architectural constraints.

Real-World Use Cases

  1. Real-Time Chat Applications: Use state containers to handle dynamic user states, message lists, and notifications effectively.
  2. Form State Management: A state container can manage complex form validation states dynamically across several stages or views.
  3. Interactive Dashboards: Utilize state containers for syncing and managing data visualizations with user input.

Conclusion

Designing a scalable state container in Vanilla JavaScript can enhance the performance and maintainability of applications without the overhead of libraries. By keeping core principles in mind and implementing advanced strategies thoughtfully, developers can create a powerful and flexible architecture that meets the demands of modern web experiences.

References and Additional Resources

The journey to creating a solid state container doesn't stop here — encourage experimentation with your designs, continuously iterating to meet the specific requirements of your applications while embracing contemporary JavaScript paradigms and techniques.

Top comments (0)