DEV Community

Cover image for I Built a State Management Library for Blazor and It's Stupidly Simple
Mashrul Haque
Mashrul Haque

Posted on

I Built a State Management Library for Blazor and It's Stupidly Simple

Look, I'll be honest with you. I've written my fair share of Redux reducers. I've stared at action creators at 2 AM wondering why my state wasn't updating. I've debugged Flux architectures until my eyes bled.

Then I started working with Blazor and thought: "Great, now I get to do this all over again in C#."

Except... I didn't have to.

The Problem With "Enterprise" State Management

Here's a pattern you've probably seen a thousand times:

// Define the action
public record IncrementAction(int Amount);

// Define the reducer
public static CounterState Reduce(CounterState state, object action)
{
    return action switch
    {
        IncrementAction a => state with { Count = state.Count + a.Amount },
        _ => state
    };
}

// Dispatch it somewhere
dispatcher.Dispatch(new IncrementAction(1));
Enter fullscreen mode Exit fullscreen mode

Three files. Three concepts. One simple operation: add 1 to a number.

I kept asking myself: why do we need all this ceremony? React figured it out with Zustand and Jotai. Why is .NET still stuck in 2015?

What If State Management Was Just... Methods?

Here's the same thing in EasyAppDev.Blazor.Store:

public record CounterState(int Count)
{
    public CounterState Increment() => this with { Count = Count + 1 };
}
Enter fullscreen mode Exit fullscreen mode

That's it. That's the whole thing.

Your state is a C# record. Your "actions" are just methods that return new state. No dispatchers. No reducers. No action types. Just pure functions on immutable data.

And in your component:

@inherits StoreComponent<CounterState>

<h1>@State.Count</h1>
<button @onclick="@(() => Update(s => s.Increment()))">+</button>
Enter fullscreen mode Exit fullscreen mode

The base class handles subscriptions automatically. When state changes, your component re-renders. When your component disposes, the subscription cleans up. You write zero boilerplate.

"But What About Complex Async Operations?"

Ah yes, the classic "but what about" objection. Fair enough.

Let me tell you about last Tuesday. I was building a login form. You know the drill: loading state, error handling, success redirect. Usually this means writing the same fifteen lines of try-catch-finally nonsense.

Here's what I wrote instead:

await ExecuteAsync(
    () => AuthService.LoginAsync(email, password),
    loading: s => s with { User = s.User.ToLoading() },
    success: (s, user) => s with { User = AsyncData<User>.Success(user) },
    error: (s, ex) => s with { User = AsyncData<User>.Failure(ex.Message) }
);
Enter fullscreen mode Exit fullscreen mode

One method call. Loading, success, and error states all handled. The AsyncData<T> wrapper tracks everything: IsLoading, HasData, HasError, even IsNotAsked for data that hasn't been fetched yet.

58% less code. And it actually reads like what it does.

The "Oh Crap, I Rendered 47 Times" Problem

Here's something that bit me early on. I had a dashboard with multiple widgets, all subscribed to the same store. Every state change re-rendered everything. Performance tanked.

The solution? Selector components:

@inherits SelectorStoreComponent<AppState, int>

<h1>@State</h1>

@code {
    protected override int Selector(AppState state) => state.Count;
}
Enter fullscreen mode Exit fullscreen mode

Now this component only re-renders when Count changes. Not when UserName changes. Not when CartItems changes. Just Count.

In my benchmarks, this reduced unnecessary renders by 25x in high-frequency update scenarios. That's not a typo. Twenty-five times.

Redux DevTools, Because We're Not Animals

Okay, I'll admit it. One thing I genuinely miss from the Redux ecosystem is DevTools. Time-travel debugging is addictive once you've used it.

Good news: this library has full Redux DevTools integration. Just add the middleware:

builder.Services.AddStore(
    new CounterState(0),
    (store, sp) => store.WithDefaults(sp, "Counter")
);
Enter fullscreen mode Exit fullscreen mode

Open your browser, open Redux DevTools, and boom - you can see every state change, diff states, replay actions, even export and import state snapshots.

The implementation handles Blazor's weirdness too. In Server mode, IJSRuntime is scoped, so DevTools gracefully disables itself for singleton stores. For scoped stores (per-user state), it works perfectly. In WebAssembly, everything just works.

The Five Async Helpers That Changed My Life

I'm being dramatic. They didn't change my life. But they did save me from writing the same boilerplate over and over.

Helper What It Does Code Reduction
UpdateDebounced Wait for user to stop typing 94%
UpdateThrottled Limit update frequency 95%
AsyncData<T> Track loading/success/error 95%
ExecuteAsync Automatic error boundaries 58%
LazyLoad Cache with deduplication 85%

That debounce one is particularly satisfying. Compare this:

// Before: 17 lines of cancellation token management
private CancellationTokenSource? _cts;

private async Task Search(string query)
{
    _cts?.Cancel();
    _cts = new CancellationTokenSource();
    try
    {
        await Task.Delay(300, _cts.Token);
        await UpdateAsync(s => s.PerformSearch(query));
    }
    catch (TaskCanceledException) { }
}
Enter fullscreen mode Exit fullscreen mode

To this:

// After: 1 line
await UpdateDebounced(s => s with { Query = query }, 300, "search");
Enter fullscreen mode Exit fullscreen mode

Same behavior. Fraction of the code.

The Architecture (For Those Who Care)

Under the hood, the library uses interface segregation. Your store implements three focused interfaces:

  • IStateReader<T> - Just GetState()
  • IStateWriter<T> - Just Update() and UpdateAsync()
  • IStateObservable<T> - Just Subscribe()

Components can depend on only what they need. Want read-only access? Inject IStateReader<CounterState>. Need to update state from a service? Inject IStateWriter<CounterState>.

This isn't just academic. It makes testing trivial. Mock only what you use.

The store itself uses SemaphoreSlim for thread safety, with some clever AsyncLocal<int> tracking to detect reentrancy and avoid deadlocks. State changes notify subscribers outside the lock, so you won't freeze your UI.

What I Got Wrong (And Fixed)

Early versions had a synchronous Update() method. It worked fine until someone tried updating two stores from the same handler. Deadlock city.

Now there's UpdateAsync() as the primary method, with Update() marked obsolete. The library will warn you if it detects reentrancy patterns.

I also initially tried to make DevTools work in all Blazor modes with the same code path. Turns out, IJSRuntime being scoped in Blazor Server means you can't resolve it during singleton store creation. The fix? Lazy resolution with graceful degradation. If JS isn't available, DevTools silently no-ops.

Should You Use This?

If you're building a Blazor app and you want state management that:

  • Uses plain C# records (no framework-specific concepts)
  • Enforces immutability at the compiler level
  • Handles subscriptions automatically
  • Works across Server, WebAssembly, and Auto modes
  • Gives you Redux DevTools for debugging
  • Weighs 38KB gzipped

Then yeah, give it a shot.

If you're already happy with your current solution, or you need something that integrates with a specific backend pattern, maybe not. I'm not here to convert anyone.

Getting Started

dotnet add package EasyAppDev.Blazor.Store
Enter fullscreen mode Exit fullscreen mode
// Program.cs
builder.Services.AddStoreUtilities();
builder.Services.AddStore(new CounterState(0));
Enter fullscreen mode Exit fullscreen mode
@* Counter.razor *@
@inherits StoreComponent<CounterState>

<button @onclick="@(() => Update(s => s.Increment()))">
    Count: @State.Count
</button>
Enter fullscreen mode Exit fullscreen mode

That's literally it. No configuration files. No provider wrappers. No context consumers.

The GitHub repo has more examples, including async operations, persistence, and the selector pattern.

Final Thoughts

State management doesn't have to be complicated. Sometimes the best abstraction is no abstraction - just immutable records and pure functions.

The Blazor ecosystem is maturing fast. We don't need to copy React's patterns wholesale. C# has records, pattern matching, and a type system that actually helps you. Let's use them.

Now if you'll excuse me, I have 47 more features I want to add to this thing. But I won't. Because sometimes the best code is the code you don't write.

Built something cool with EasyAppDev.Blazor.Store? Hit me up. I'd love to see it.

About me

I’m a Systems Architect who is passionate about distributed systems, .Net clean code, logging, performance, and production debugging.

  • 🧑‍💻 Check out my projects on GitHub: mashrulhaque
  • 👉 Follow me here on dev.to for more .NET posts

Top comments (0)