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.
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.
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.
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.
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:
<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.
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.
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:
- No DOM pollution - States exist in
ElementInternals, not as attributes - CSS integration - The
:state()pseudo-class targets states directly - Encapsulation - External code cannot manipulate internal states
- Accessibility -
ElementInternalsprovides 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.
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 instanceconst appState = new StateManager({ user: null, theme: 'light' });Components subscribe and unsubscribe through lifecycle hooks:
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.ariaBusypairs cleanly withstates.add('loading')- Visual state separated from semantic state
- Screen readers receive proper notifications
Common Mistakes I Made
Mistake 1: Assuming auto-render
// WRONGthis._state.count = 5; // Nothing updates
// CORRECTset count(value) { this._count = value; this.render();}Mistake 2: Mixing styling and semantics
// WRONG: Using aria-busy for stylingthis.setAttribute('aria-busy', 'true'); // Pollutes semantics
// CORRECT: Separate concernsthis._internals.states.add('loading'); // For CSSthis._internals.ariaBusy = 'true'; // For screen readersMistake 3: Forgetting cleanup
// WRONG: No unsubscribeconnectedCallback() { appState.subscribe(state => this.render(state));}
// CORRECT: Proper lifecycle managementconnectedCallback() { 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:
- Internal properties for private state with getter/setter patterns
- Attributes for public, declarative configuration
- 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:
- 👨💻 MDN Web Docs - Custom State Pseudo-class Selector
- 👨💻 Web Components Introduction on MDN
- 👨💻 Reddit Discussion on Web Components State Management
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments