Skip to content

How do you manage state in Web Components without React or frameworks?

I walked into a technical interview confident in my React skills. The interviewer asked: “How would you manage state in a Web Component?” I froze. I had spent two years mastering useState, useEffect, Redux, Context API - but none of that translated to vanilla Web Components.

The interviewer’s company worked extensively with Web Components. They weren’t looking for framework knowledge. They wanted to know how I’d handle state without React’s magic.

I fumbled through an answer about using JavaScript objects and attributes. The interviewer nodded politely and moved on. I left knowing I had failed that question.

The Core Problem: No Built-in Reactivity

React developers live in a declarative world. You call setState, and React handles everything - diffing, re-rendering, updating the DOM. Web Components exist in an imperative world. State changes don’t trigger anything automatically. You must manually update the DOM yourself.

This fundamental difference caught me off guard. I expected Web Components to work like mini-React components. They don’t.

naive-approach.js
class MyComponent extends HTMLElement {
constructor() {
super();
this.state = { count: 0 }; // This does nothing
}
increment() {
this.state.count++; // No re-render happens
// The UI never updates!
}
}

I wrote the above code assuming React-like behavior. Nothing happened. The counter stayed at zero. Web Components don’t watch your state object. They don’t know when you’ve changed anything.

My First Working Solution: Manual Rendering

I went back to basics. If React wasn’t going to update my DOM, I’d do it myself.

manual-render-counter.js
class Counter extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._count = 0;
this.shadowRoot.innerHTML = `
<button id="btn">Count: <span id="display">0</span></button>
`;
}
connectedCallback() {
this.shadowRoot.querySelector('#btn').addEventListener('click', () => {
this.increment();
});
}
increment() {
this._count++;
this.render();
}
render() {
this.shadowRoot.querySelector('#display').textContent = this._count;
}
}
customElements.define('simple-counter', Counter);

This worked, but it felt primitive. Every state change required a manual render() call. I was doing what React does automatically, but clumsily and with more code.

The pattern revealed itself: getter/setter pairs that trigger rendering.

getter-setter-pattern.js
class TodoItem extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._completed = false;
this._text = '';
this.shadowRoot.innerHTML = `
<style>
.completed { opacity: 0.5; text-decoration: line-through; }
</style>
<span class="text"></span>
<button class="toggle">Toggle</button>
`;
}
// Getter - read the private state
get completed() {
return this._completed;
}
// Setter - update state AND trigger render
set completed(value) {
this._completed = value;
this.render();
}
connectedCallback() {
this._text = this.getAttribute('text') || 'Untitled';
this.render();
this.shadowRoot.querySelector('.toggle')
.addEventListener('click', () => {
this.completed = !this.completed;
});
}
render() {
const span = this.shadowRoot.querySelector('.text');
span.textContent = this._text;
span.classList.toggle('completed', this._completed);
}
}
customElements.define('todo-item', TodoItem);

The getter/setter pattern gave me controlled state updates. Setting completed automatically refreshed the UI. This felt more structured than scattering render() calls everywhere.

Learning About Attributes: Public State

The interviewer had mentioned “attributes” as state. I initially dismissed them - attributes felt like static configuration, not dynamic state. Then I needed a component that accepted external configuration.

theme-component.js
class ThemeButton extends HTMLElement {
static get observedAttributes() {
return ['theme', 'disabled'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host([theme="dark"]) button { background: #333; color: #fff; }
:host([theme="light"]) button { background: #fff; color: #333; }
:host([disabled]) button { opacity: 0.5; cursor: not-allowed; }
</style>
<button>Click me</button>
`;
}
attributeChangedCallback(name, oldValue, newValue) {
// This fires when ANY observed attribute changes
console.log(`${name} changed from ${oldValue} to ${newValue}`);
if (name === 'theme') {
// CSS handles styling via :host selectors
}
if (name === 'disabled') {
this.shadowRoot.querySelector('button').disabled = newValue !== null;
}
}
}
customElements.define('theme-button', ThemeButton);

Now I could declaratively configure the component in HTML:

usage.html
<theme-button theme="dark"></theme-button>
<theme-button theme="light" disabled></theme-button>

Attributes worked well for configuration, but I ran into trouble using them for transient state.

The Mistake: Polluting Attributes with Runtime State

I tried storing loading state in an attribute. It worked technically but created problems.

wrong-approach.js
class AsyncButton extends HTMLElement {
async submit() {
this.setAttribute('loading', 'true'); // Pollutes DOM
try {
await fetch('/api/submit');
this.removeAttribute('loading');
this.setAttribute('success', 'true');
} catch (err) {
this.removeAttribute('loading');
this.setAttribute('error', 'true');
}
}
}

The DOM became littered with transient attributes. MutationObservers watching the component fired unnecessarily. The attributes were visible to external code that shouldn’t touch internal state.

I needed a way to track styling state without polluting the DOM.

Discovering CustomStateSet API

Research led me to ElementInternals and CustomStateSet. This API, relatively new to browsers, provides state management specifically for styling - without DOM pollution.

custom-state-button.js
class AsyncButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._internals = this.attachInternals();
this.shadowRoot.innerHTML = `
<style>
button { padding: 10px 20px; border: none; cursor: pointer; }
/* The :state() pseudo-class targets CustomStateSet states */
:host(:state(loading)) button {
background: #ccc;
cursor: wait;
}
:host(:state(success)) button {
background: #4CAF50;
color: white;
}
:host(:state(error)) button {
background: #f44336;
color: white;
}
</style>
<button>Submit</button>
`;
}
connectedCallback() {
this.shadowRoot.querySelector('button')
.addEventListener('click', () => this.submit());
}
async submit() {
const states = this._internals.states;
// Clear previous states
states.delete('success');
states.delete('error');
// Enter loading state
states.add('loading');
this._internals.ariaBusy = 'true';
try {
await fetch('/api/submit');
states.delete('loading');
states.add('success');
} catch (err) {
states.delete('loading');
states.add('error');
}
this._internals.ariaBusy = 'false';
}
}
customElements.define('async-button', AsyncButton);

The CustomStateSet approach solved multiple problems:

  1. No DOM pollution - States exist in ElementInternals, not as attributes
  2. CSS integration - The :state() pseudo-class targets states directly
  3. Encapsulation - External code cannot manipulate internal states
  4. Accessibility - ElementInternals provides direct ARIA property access

Building a Complete State Management System

Combining all three approaches gave me a structured state hierarchy:

Tier 1: Internal Properties (Private State)

  • Component-internal data that never needs external access
  • Use getter/setter pattern with manual render triggers

Tier 2: Attributes (Public Configuration)

  • Declarative HTML configuration
  • Listed in observedAttributes
  • Trigger attributeChangedCallback

Tier 3: CustomStateSet (Styling State)

  • Transient visual states (loading, error, success)
  • Pure CSS consumption via :state() pseudo-class
  • No DOM serialization overhead

Cross-Component Communication: Observer Pattern

The final missing piece was state sharing between components. React solves this with Context or global stores. For Web Components, I implemented the Observer pattern.

state-manager.js
class StateManager {
constructor(initialState = {}) {
this.state = initialState;
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
return () => {
this.observers = this.observers.filter(obs => obs !== observer);
};
}
setState(newState) {
this.state = { ...this.state, ...newState };
this.notifyObservers();
}
notifyObservers() {
this.observers.forEach(observer => observer(this.state));
}
}
// Global state instance
const appState = new StateManager({ user: null, theme: 'light' });

Components subscribe and unsubscribe through lifecycle hooks:

subscribed-component.js
class UserProfile extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// Subscribe on mount
this.unsubscribe = appState.subscribe(state => this.render(state));
this.render(appState.state);
}
disconnectedCallback() {
// Unsubscribe on removal - critical for preventing memory leaks
this.unsubscribe();
}
render(state) {
this.shadowRoot.innerHTML = `
<div>User: ${state.user || 'Guest'}</div>
<button>Login</button>
`;
this.shadowRoot.querySelector('button')
.addEventListener('click', () => {
appState.setState({ user: 'John' });
});
}
}
customElements.define('user-profile', UserProfile);

The Observer pattern gives React-like global state without any framework dependency.

Why This Matters

The three-tier state architecture addresses specific concerns:

Performance:

  • Attributes trigger DOM serialization (expensive)
  • CustomStateSet bypasses DOM entirely
  • Internal properties have zero DOM overhead

Encapsulation:

  • CustomStateSet states are private - external scripts cannot manipulate them
  • Attributes are public by design - intentional API exposure

Accessibility:

  • ElementInternals.ariaBusy pairs cleanly with states.add('loading')
  • Visual state separated from semantic state
  • Screen readers receive proper notifications

Common Mistakes I Made

Mistake 1: Assuming auto-render

no-render.js
// WRONG
this._state.count = 5; // Nothing updates
// CORRECT
set count(value) {
this._count = value;
this.render();
}

Mistake 2: Mixing styling and semantics

aria-confusion.js
// WRONG: Using aria-busy for styling
this.setAttribute('aria-busy', 'true'); // Pollutes semantics
// CORRECT: Separate concerns
this._internals.states.add('loading'); // For CSS
this._internals.ariaBusy = 'true'; // For screen readers

Mistake 3: Forgetting cleanup

cleanup.js
// WRONG: No unsubscribe
connectedCallback() {
appState.subscribe(state => this.render(state));
}
// CORRECT: Proper lifecycle management
connectedCallback() {
this.unsubscribe = appState.subscribe(state => this.render(state));
}
disconnectedCallback() {
this.unsubscribe();
}

Summary

Web Components state management requires explicit control. There’s no magic reactivity system. You choose:

  1. Internal properties for private state with getter/setter patterns
  2. Attributes for public, declarative configuration
  3. CustomStateSet for styling-only transient states

For cross-component state, implement Observer pattern or use a lightweight store.

This approach trades React’s convenience for framework independence. The cost is explicit rendering responsibility. The benefit is full control without external dependencies.

If you’re transitioning from React, expect friction. The declarative-to-imperative shift requires unlearning patterns that React made automatic. But once internalized, Web Components state management becomes straightforward - just more manual.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments