
Read this article on Medium.
Blazor is a powerful framework for building interactive web applications with C#. But it’s easy to fall into architectural traps that hurt maintainability, performance, and scalability. Here are the top 10 mistakes developers make — and how to fix them.
1. Doing Too Much in Components
The Problem
In Blazor (and component-based frameworks in general), components are meant to represent a piece of the UI, handle user interactions, and manage rendering efficiently. A common mistake is to let components do everything:
- Fetching and transforming data
- Implementing business rules
- Managing complex state
- Handling side effects (like logging or notifications)
When components mix these responsibilities, they become hard to maintain, hard to test, and hard to reuse. Any small change in business logic forces you to touch the component, increasing the risk of bugs.
The Solution
Keep components lean and declarative:
- Components should focus on displaying data and responding to UI events.
- Move data fetching, transformation, and business logic into services.
- Use Dependency Injection (DI) to inject services into components.
- This separation makes your code testable, maintainable, and reusable.
❌ Bad Example: Everything in the component
- The component is tightly coupled to data access (
HttpClient) and business rules (IsActive). - Hard to test: You’d need a running API to test the component.
- Violates Single Responsibility Principle (SRP).
@code {
private List<User> Users;
protected override async Task OnInitializedAsync() {
// Fetching data directly
Users = await Http.GetFromJsonAsync<List<User>>("api/users");
// Filtering business logic in the component
Users = Users.Where(u => u.IsActive).ToList();
}
}
✅ Good Example: Delegate logic to a service
@inject IUserService UserService
@code {
private List<User> Users;
protected override async Task OnInitializedAsync() {
// Component just displays the result
Users = await UserService.GetActiveUsersAsync();
}
}
IUserService:
public interface IUserService
{
Task<List<User>> GetActiveUsersAsync();
}
public class UserService : IUserService
{
private readonly HttpClient _http;
public UserService(HttpClient http) => _http = http;
public async Task<List<User>> GetActiveUsersAsync()
{
var users = await _http.GetFromJsonAsync<List<User>>("api/users");
return users.Where(u => u.IsActive).ToList();
}
}
- Component now focuses purely on UI.
- Business logic lives in a service, which can be unit-tested easily.
- Component is simpler, reusable, and declarative.
Key Takeaways
- Components = UI + event handling only.
- Services = data access + business rules.
- Reduces complexity, improves testability, and follows SOLID principles.
- Makes future refactoring easier — you can swap services without touching components.
2. No Feature Isolation
The Problem
A common mistake in Blazor projects is organizing code by type rather than by feature:
/Components
UsersList.razor
UsersDetail.razor
/Services
UserService.cs
/Models
User.cs
- Components, services, and models for the same feature are scattered across multiple folders.
- Leads to tight coupling, making it harder to understand, modify, or test a single feature.
- Adding or removing a feature can involve touching multiple unrelated files.
- Harder for new developers to navigate the project.
The Solution
Adopt a vertical slice / feature folder structure:
Organize all code related to a feature in a single folder.
- Each feature has its own:
- Components
- Services
- Models / DTOs
- State management classes (if needed)
Features become self-contained and reusable.
Benefits:
- Better maintainability — changes to a feature are localized.
- Easier testing — all related classes are grouped, making unit testing straightforward.
- Scalability — adding new features doesn’t clutter global folders.
- Clearer separation — reduces tight coupling between features.
Example: Feature Folder Structure
/Features
/Users
UsersList.razor // Component
UsersService.cs // Service for business/data logic
UsersDto.cs // DTOs for API or UI binding
/Articles
ArticlesList.razor
ArticlesService.cs
ArticlesDto.cs
Usage in a component:
@inject UsersService UserService
@code {
private List<UserDto> Users;
protected override async Task OnInitializedAsync()
{
Users = await UserService.GetActiveUsersAsync();
}
}
- Component only depends on its feature service, not a global UserService elsewhere.
- Makes it easy to move the feature, split it, or reuse it in another project.
Key Takeaways
- Group by feature, not by type.
- Each feature should be self-contained, with everything it needs inside its folder.
- Improves navigation, readability, and testability.
- Supports scalable, maintainable, and modular applications.
3. Overusing Cascading Parameters
The Problem
Cascading parameters in Blazor allow parent components to provide values to all descendants in the component tree without explicitly passing them down. While convenient, overusing them can create spaghetti code:
- Components become tightly coupled to global state.
- Hard to trace where a value is coming from.
- Increases the risk of unexpected side effects when multiple features rely on the same cascaded data.
- Reduces reusability — a component might only work in one specific hierarchy.
❌Example of overuse (bad pattern):
@code {
[CascadingParameter]
public List<User> Users { get; set; } = default!;
[CascadingParameter]
public string CurrentTheme { get; set; } = default!;
}
- Every feature now depends on multiple cascaded values.
- Adding a new component might require changes to parent components.
The Solution
Use cascading parameters sparingly for truly global or shared context:
- Authentication/authorization state
- Theme or UI settings
- Localization/global configuration
For feature-specific data, always prefer explicit parameters.
✅Good Usage Example
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly" />
</CascadingAuthenticationState>
CascadingAuthenticationStateprovides a global auth context, which multiple components may need.- Each feature’s components receive only what they need through explicit parameters:
<UserCard User="@user" />
@code {
[Parameter] public UserDto User { get; set; } = default!;
}
UserCard is now self-contained and reusable, not tied to a cascade.
Key Takeaways
- Reserve cascading parameters for truly global state, not feature-specific data.
- Pass feature data via explicit parameters to keep components declarative.
- Prevents spaghetti-like dependencies and improves maintainability.
- Makes components easier to test and reuse in different parts of the app.
4. Chaining Async Calls in UI
The Problem
In Blazor, it’s common to call services to fetch data during component initialization. A common mistake is awaiting each call sequentially, even when the calls are independent:
protected override async Task OnInitializedAsync() {
var users = await UserService.GetUsersAsync();
var orders = await OrderService.GetOrdersAsync();
}
- Each call waits for the previous one to finish, even if they don’t depend on each other.
- This can unnecessarily block the UI, making the page feel slow.
- As your app scales with multiple independent data sources, sequential calls accumulate latency.
The Solution
Parallelize independent asynchronous calls using Task.WhenAll:
✅Good Example:
protected override async Task OnInitializedAsync() {
// Start both tasks without awaiting
var usersTask = UserService.GetUsersAsync();
var ordersTask = OrderService.GetOrdersAsync();
// Await both tasks to complete in parallel
await Task.WhenAll(usersTask, ordersTask);
// Retrieve results
var users = await usersTask;
var orders = await ordersTask;
}
usersTaskandordersTaskstart executing simultaneously.Task.WhenAllwaits for both tasks to finish, without blocking the UI unnecessarily.- Improves perceived performance, especially when multiple independent data sources are involved
Tips for Using Async in UI
- Only parallelize independent calls — if one call depends on the result of another, keep it sequential.
- Avoid fire-and-forget unless side effects are safe and exceptions are handled.
- Use async all the way — don’t block async calls with
.Resultor.Wait(). - Combine with services and DTOs to keep UI components clean and focused.
Key Takeaways
- Sequential awaits can slow down your UI unnecessarily.
- Parallelize independent tasks with
Task.WhenAllto reduce load times. - Keep components declarative, letting services handle the heavy lifting.
- This pattern is especially valuable in Blazor SSR and WebAssembly, where network latency matters.
5. Storing Usernames Inside Data Rows
The Problem
A common temptation is to store redundant data like usernames or display names directly in your database tables:
❌Bad Example:
public class Article
{
public int Id { get; set; }
public int UserId { get; set; }
public string UserName { get; set; } // Redundant!
}
- Leads to denormalization, meaning the same data exists in multiple places.
- If the user changes their username, you must update it in all tables, creating a risk of stale data.
- Harder to maintain, especially in large systems with multiple tables referencing the same entity.
The Solution
Follow database normalization principles: store only the user ID in related tables and fetch user details dynamically when needed.
✅Good Example:
public class Article
{
public int Id { get; set; }
public int UserId { get; set; }
}
Use a DTO to present the data in the UI:
public record ArticleDto(int Id, string Title, string UserName);
Mapping example (service layer):
public async Task<List<ArticleDto>> GetArticlesAsync()
{
var articles = await _context.Articles.ToListAsync();
var users = await _context.Users.ToDictionaryAsync(u => u.Id, u => u.UserName);
return articles.Select(a => new ArticleDto(
a.Id,
a.Title,
users[a.UserId] // Fetch username dynamically
)).ToList();
}
- Articles remain normalized, storing only
UserId. - UI still displays the username, but it’s pulled dynamically from the Users table.
- Prevents stale data issues when user details change.
Best Practices
- Always store IDs, not display values, in related tables.
- Use DTOs to shape data for the UI without polluting your database.
- Fetch related data efficiently — consider joins or dictionary lookups to avoid N+1 query problems.
- Keep business logic in services; components should only consume DTOs.
Key Takeaways
- Redundant data in rows = maintenance headache.
- Storing only IDs keeps your database normalized and consistent.
- DTOs allow the UI to remain simple while still displaying all necessary info.
- Aligns with clean architecture principles — separation of persistence and presentation.
6. Mixing SSR and WASM Assumptions
The Problem
Blazor supports multiple hosting models:
- Blazor WebAssembly (WASM) — runs entirely in the browser
- Blazor Server Side Rendering (SSR) — runs on the server and updates the UI over SignalR
A common mistake is assuming all code works the same way in both environments:
- Using synchronous I/O in SSR can block the server.
- Accessing browser-only APIs (like
localStorage) directly will break SSR. - Authentication and user context may be handled differently between SSR and WASM.
This can lead to runtime errors that only appear in one environment.
The Solution
Abstract environment-specific logic so your components and services remain environment-agnostic:
✅Good Example:
public interface IEnvironmentService
{
bool IsServerSide();
bool IsWebAssembly();
}
Implementation for SSR:
public class ServerEnvironmentService : IEnvironmentService
{
public bool IsServerSide() => true;
public bool IsWebAssembly() => false;
}
Implementation for WASM:
public class WasmEnvironmentService : IEnvironmentService
{
public bool IsServerSide() => false;
public bool IsWebAssembly() => true;
}
Usage in a component/service:
@inject IEnvironmentService EnvService
@code {
protected override void OnInitialized()
{
if (EnvService.IsServerSide())
{
// Use server-friendly logic
}
else
{
// Use browser APIs safely
}
}
}
- Components now don’t need to know the underlying hosting model.
- Allows you to share components between WASM and SSR safely.
- Makes testing easier — you can mock
IEnvironmentServiceto simulate each environment.
Best Practices
- Avoid direct environment assumptions in UI or services.
- Wrap environment-specific logic in services or abstractions.
- Async I/O is preferred in SSR to avoid blocking the server.
- For browser-only APIs (like
localStorage), create wrapper services that provide a safe abstraction. - Keep components declarative, relying on injected services for environment differences.
Key Takeaways
- WASM ≠ SSR — they have different execution contexts.
- Use abstractions/services to handle environment-specific logic.
- Prevents runtime errors and allows shared components between hosting models.
- Encourages clean, testable, and maintainable code.
7. No Shared Contracts
The Problem
In many Blazor and .NET projects, developers define API models on the server and DTOs or models on the client separately. Over time, these can drift apart:
- API changes aren’t reflected in the UI model.
- Mapping code becomes repetitive and error-prone.
- Leads to runtime bugs, like missing fields or mismatched types.
❌Bad Example:
// API model
public class User
{
public int Id { get; set; }
public string FirstName { get; set; } = default!;
public string LastName { get; set; } = default!;
public string Email { get; set; } = default!;
}
// UI model (sometimes defined separately)
public class UserDto
{
public int Id { get; set; }
public string FullName { get; set; } = default!;
public string Email { get; set; } = default!;
}
- Now you need mapping logic between
UserandUserDto. - If the API adds a property and the UI doesn’t update, bugs appear silently.
The Solution
Use shared DTOs or code generation to ensure API and UI contracts stay in sync.
✅Good Example — Shared library approach:
// Shared project referenced by both API and Blazor app
public record UserDto(int Id, string FullName, string Email);
- Both the API returns
UserDtoand the UI consumes it directly. - Eliminates repetitive mapping in the client.
- Reduces the risk of drift between API and UI.
Optional code generation:
- Tools like NSwag or OpenAPI codegen can generate DTOs automatically from your API spec.
- Guarantees the client always matches the server contract.
Best Practices
- Centralize shared DTOs in a separate project/library referenced by both API and UI.
- Avoid mapping unless necessary — if mapping is needed, keep it in services, not components.
- Consider code generation for large APIs to prevent drift.
- Keep DTOs lean and focused, containing only the fields the UI needs.
Key Takeaways
- Not sharing contracts leads to mapping bugs and maintenance overhead.
- Shared DTOs or code generation keeps API and UI in sync.
- Reduces repetitive mapping logic and improves developer productivity.
- Supports clean architecture principles by separating contracts from business logic.
8. Improper DI Lifetimes
The Problem
Dependency Injection (DI) lifetimes in Blazor are critical, and misusing them can lead to unexpected behavior, memory leaks, or shared state bugs.
❌Common mistake: storing per-user state in a singleton:
builder.Services.AddSingleton<CartService>(); // ❌ Bad
- In Blazor Server, a singleton is shared across all users.
- If
CartServiceholds user-specific data (like a shopping cart), one user’s data might leak to another user. - Hard to debug and can cause serious security issues.
The Solution
Use the correct DI lifetime for the service’s intended use:
- Scoped — created once per user session/connection (ideal for Blazor Server).
- Transient — new instance every injection (stateless services).
- Singleton — one instance for the app lifetime (global, stateless).
✅Correct usage for user-specific state:
builder.Services.AddScoped<CartService>(); // ✅ Scoped for per-user state
- Each user gets a unique instance of
CartService. - Keeps state isolated per user session.
- Components can safely consume the service without leaking data.
✅Example: Scoped Service in Blazor
// CartService.cs
public class CartService
{
private readonly List<CartItem> _items = new();
public IReadOnlyList<CartItem> Items => _items;
public void AddItem(CartItem item) => _items.Add(item);
}
// Usage in a component
@inject CartService Cart
<MudButton OnClick="@(() => Cart.AddItem(new CartItem { Name = "Book" }))">
Add Item
</MudButton>
<ul>
@foreach(var item in Cart.Items)
{
<li>@item.Name</li>
}
</ul>
- Each user sees only their own cart items.
- No risk of cross-user state contamination.
Best Practices
- Scoped services are usually the safest choice for per-user state in Blazor.
- Singletons should be stateless or hold app-wide configuration, not user data.
- Transient services are good for short-lived operations (like helpers or stateless utilities).
- Review DI lifetimes carefully — misconfigured lifetimes are a common source of subtle bugs.
Key Takeaways
- Misusing DI lifetimes can lead to data leaks, bugs, and security issues.
- Scoped = per-user (Blazor Server), Singleton = shared across users, Transient = short-lived.
- Correct lifetime assignment ensures user-specific state is safe and components behave predictably.
- Always think about state ownership when registering services.
9. Ignoring State Management Patterns
The Problem
A common mistake in Blazor is managing global or shared state only through EventCallbacks, cascading parameters, or passing data manually:
- Works for small apps, but becomes hard to maintain as the app grows.
- Leads to tightly coupled components and scattered state logic.
- Components must know who owns the data and how to update it, causing duplication.
❌Bad Example:
// Bad: passing data and events through multiple components
<ParentComponent Counter="@Counter" OnIncrement="@IncrementCounter" />
Every child must wire up callbacks, even for unrelated components.
The Solution
Use a centralized state container to manage shared state.
✅Good Example:
public class AppState
{
public int Counter { get; private set; }
public event Action? OnChange;
public void Increment()
{
Counter++;
OnChange?.Invoke();
}
}
Register it as a scoped service:
builder.Services.AddScoped<AppState>();
Usage in a component:
@inject AppState State
<MudButton OnClick="@State.Increment">Increment</MudButton>
<p>Counter: @State.Counter</p>
@code {
protected override void OnInitialized()
{
State.OnChange += StateHasChanged;
}
public void Dispose()
{
State.OnChange -= StateHasChanged;
}
}
- All components that inject
AppStatesee the same counter. - Updates automatically notify components via the
OnChangeevent. - Reduces callback chaining and spaghetti state.
Best Practices
- Use state containers for app-wide or shared feature state.
- Register them as scoped in Blazor Server to maintain per-user isolation.
- Use events or observables to notify components of changes (
OnChange,INotifyPropertyChanged, or Reactive patterns). - Keep UI components declarative, letting the state container manage the data.
- Avoid overusing cascading parameters for state — reserve them for global context, not dynamic feature data.
Key Takeaways
- EventCallbacks and cascading parameters alone are not enough for complex state.
- Centralized state containers simplify shared data management, increase maintainability, and reduce coupling.
- Proper state management improves testability, predictability, and developer productivity.
- Makes scaling the app much safer and easier to reason about.
10. Mixing UI and Business Logic
The Problem
A very common Blazor mistake is embedding business rules directly inside components:
- Calculations
- Conditional rules
- Validation logic
- Domain-specific decisions
❌Bad Example:
@if (Order.Total > 100)
{
<p>Discount: 10%</p>
}
else
{
<p>No discount</p>
}
At first glance, this seems harmless — but it creates long-term issues:
- Components become harder to read as logic grows
- Business rules are duplicated across multiple components
- Logic becomes difficult to test without rendering the UI
- Violates the Single Responsibility Principle (SRP)
The Solution
Move business logic into services and let components focus solely on rendering and user interaction.
✅Good Example:
// Service
public class OrderService
{
public decimal CalculateDiscount(Order order)
=> order.Total > 100 ? 0.1m : 0m;
}
@inject OrderService OrderService
<p>Discount: @OrderService.CalculateDiscount(Order)</p>
- The component becomes declarative and readable
- Business rules live in a testable, reusable service
- Logic can evolve without touching UI components
Why This Matters
Business logic belongs in services because:
- Testability — You can unit test
OrderServicewithout rendering Blazor components - Reusability — The same logic can be used in APIs, background jobs, or other UIs
- Consistency — Rules are defined once, not copied everywhere
- Maintainability — Changes don’t ripple through the UI layer
✅Better Example: Growing Business Rules
public class OrderService
{
public decimal CalculateDiscount(Order order)
{
if (!order.IsEligible)
return 0;
if (order.Total > 500)
return 0.2m;
if (order.Total > 100)
return 0.1m;
return 0;
}
}
- This logic would be painful inside a component
- In a service, it’s clear, isolated, and easy to evolve
Key Takeaways
- UI components should render state, not decide business rules
- Business logic belongs in services or domain classes
- Separating logic improves testability, readability, and reuse
- Lean components scale better as applications grow