Chuyển đến nội dung chính

Blazor: Quản lý State trong Blazor Server với Fluxor - Part 5

Trong bài trước, chúng ta đã tìm hiểu cách Fluxor hoạt động trong môi trường Console. Tuy nhiên, sức mạnh thực sự của mô hình Flux (Redux) chỉ tỏa sáng rực rỡ khi đối mặt với sự phức tạp của UI.

Hôm nay, mình sẽ hướng dẫn các bạn tích hợp Fluxor vào Blazor Server để giải quyết bài toán truyền dữ liệu giữa các Component mà không cần dùng đến "chuỗi sự kiện" (Event Callback) rắc rối.

Trước khi đi vào chi tiết, mình sẽ nói sơ về ví dụ. Hãy tưởng tượng bạn đang xây dựng một tính năng nhỏ cho phép người dùng quản lý danh sách sách cần đọc. Các thao tác cơ bản bao gồm:
  • Thêm sách: Nhập tên sách và nhấn nút, hệ thống giả lập lưu vào Database (mất khoảng 1 giây) rồi hiển thị lên danh sách.
  • Xóa sách: Nhấn nút xóa bên cạnh mỗi đầu sách để loại bỏ khỏi danh sách.

1. Cài đặt

Cài đặt Fluxor

dotnet add package Fluxor.Blazor.Web

Đăng ký dịch vụ trong Program.cs

Thay vì đăng ký thủ công từng thành phần, Fluxor cung cấp khả năng tự động quét (Scan) toàn bộ dự án:
builder.Services.AddFluxor(options => options.ScanAssemblies(typeof(Program).Assembly));
Trong C#, một Assembly thường tương ứng với một file .dll (một Project trong Solution của bạn).

Hàm ScanAssemblies yêu cầu đầu vào là các đối tượng thuộc kiểu Assembly. Tuy nhiên, thay vì phải viết đường dẫn file vật lý (rất dễ lỗi), chúng ta sử dụng cách "mượn" một Class đại diện

  • typeof(Program): Lấy thông tin kiểu dữ liệu (Type) của class Program.
  • .Assembly: Truy xuất xem class đó đang nằm ở Project (DLL) nào.
Ví dụ: Nếu Program nằm trong project Program.dll, thì typeof(Program).Assembly sẽ trả về chính file DLL đó. Fluxor sẽ mở "vỏ" file này ra và tìm tất cả các class có đánh dấu [FeatureState], [ReducerMethod], hay [EffectMethod].

Xử lý với Solution có nhiều Libraries (Projects)

Trong một Clean Architecture, bạn thường chia nhỏ Solution:
  • Project Web (UI): Chứa Program.cs.
  • Project State (Domain): Chứa các logic nghiệp vụ, State, Reducer.
Cách lấy Assembly cho từng Project:

Bạn chỉ cần chọn một Class bất kỳ (thường là một class rỗng hoặc class cấu hình) trong mỗi Project đó để làm "điểm neo":

options.ScanAssemblies(
    typeof(Program).Assembly,         // Scan Project Web (UI)
    typeof(RootState).Assembly,       // Scan Project chứa State/Logic
    typeof(SharedElements).Assembly   // Scan Project chứa các thành phần chung
);

2. Render Mode & Fluxor Store

Trong kiến trúc Blazor mới (từ .NET 8 trở đi), việc đặt <StoreInitializer /> không còn đơn giản như các phiên bản cũ. Bạn cần hiểu rõ cơ chế Render Mode để tránh lỗi Store không hoạt động hoặc không cập nhật UI.

🧠 Tại sao đặt ở App.razor lại không chạy?
File App.razor mặc định chạy ở chế độ Static Server. Nó chỉ kết xuất HTML một lần rồi kết thúc tác vụ. Trong khi đó, Fluxor cần một "mạch" (Circuit) hoạt động liên tục để quản lý State. Nếu đặt ở đây, Store sẽ bị "chết" ngay sau khi trang load xong.

Các mode mà StoreInitializer hỗ trợ: @rendermode InteractiveServer, InteractiveWebAssembly, hoặc InteractiveAuto

Giải pháp: Cấu hình Global Interactivity

Đây là cách tối ưu nhất để có một Store duy nhất chạy toàn cục mà không phải khai báo lặp lại ở từng trang con.

Bước 1: Kích hoạt tại App.razor

Chúng ta sẽ chuyển tính tương tác xuống component <Routes />.

<!-- File: App.razor -->
<!DOCTYPE html>
<html lang="en">
<head>
    <HeadOutlet />
</head>
<body>
    <!-- Kích hoạt InteractiveServer toàn cục tại đây -->
    <Routes @rendermode="InteractiveServer" />
    
    <script src="_framework/blazor.web.js"></script>
</body>
</html>

Bước 2: Khởi tạo Store tại Routes.razor

Routes đã có tính tương tác, hãy đặt StoreInitializer vào đây để nó bao phủ toàn bộ ứng dụng.

<!-- File: Routes.razor -->
<Fluxor.Blazor.Web.StoreInitializer />

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
    </Found>
    <NotFound>
        <PageTitle>Not found</PageTitle>
        <LayoutView Layout="@typeof(MainLayout)">
            <p role="alert">Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>
💡 Mẹo nhỏ: Khi đã dùng cách Global này, bạn có thể xóa bỏ dòng @rendermode InteractiveServer ở đầu các file trang con như Counter.razor. Các page kế thừa sẽ tự động thừa hưởng "rendermode" tương tác từ Parent, giúp source code của bạn gọn gàng hơn đáng kể.

 

3. Tạo trang Book.razor

Bạn tạo trang Book.razor
@page "/book"
@using BlazorWebSample.State.Book
@using Fluxor
@inherits Fluxor.Blazor.Web.Components.FluxorComponent

@inject IState<BookState> BookState
@inject IDispatcher Dispatcher

<PageTitle>Books</PageTitle>

<h1>Book Management</h1>

<div class="mb-3 d-flex">
    <input type="text" class="form-control me-2" placeholder="Enter book title..." @bind="newBookTitle" @bind:event="oninput" @onkeyup="HandleKeyUp" />
    <button class="btn btn-primary" @onclick="AddBook" disabled="@string.IsNullOrWhiteSpace(newBookTitle)">Add</button>
</div>

<h3>Book List</h3>
<ul class="list-group mb-3">
    @foreach (var book in BookState.Value.Books)
    {
        <li class="list-group-item d-flex justify-content-between align-items-center">
            @book
            <button class="btn btn-danger btn-sm" @onclick="() => RemoveBook(book)">Xóa</button>
        </li>
    }
</ul>

@if (!BookState.Value.Books.Any())
{
    <p class="text-muted">Chưa có sách nào trong danh sách.</p>
}
else
{
    <p class="text-muted">Tổng số sách: @BookState.Value.Books.Count</p>
}

@code {
    private string newBookTitle = string.Empty;

    private void AddBook()
    {
        if (!string.IsNullOrWhiteSpace(newBookTitle))
        {
            Dispatcher.Dispatch(new AddBookAction(newBookTitle));
            newBookTitle = string.Empty;
        }
    }

    private void HandleKeyUp(KeyboardEventArgs e)
    {
        if (e.Key == "Enter")
        {
            AddBook();
        }
    }

    private void RemoveBook(string title)
    {
        Dispatcher.Dispatch(new RemoveBookAction(title));
    }
}

Flux Pattern

Flux là một kiến trúc quản lý dữ liệu một chiều (Unidirectional Data Flow), giúp mọi thay đổi trong ứng dụng trở nên minh bạch và dễ dự đoán. Thay vì để các Component tự ý thay đổi dữ liệu của nhau, Flux bắt buộc mọi thứ phải đi qua một quy trình khép kín:
graph LR A[Action] --> B[Dispatcher] B --> C[Store] C --> D[View] D -.->|Trigger new action| A style A fill:#4a6572,color:#fff,stroke:#232f34 style B fill:#d8c9e0,color:#000,stroke:#8e7cc3 style C fill:#cfe2f3,color:#000,stroke:#6fa8dc style D fill:#d9ead3,color:#000,stroke:#93c47d

Luồng hoạt động của Flux trong ví dụ Reading List:

  1. Action (Hành động): Khi bạn nhấn nút "Add Book", một Action mang tên AddBookAction kèm tiêu đề sách được gửi đi. Đây là tín hiệu thông báo cho toàn hệ thống biết một sự kiện vừa xảy ra.
  2. Dispatcher (Người điều phối): Fluxor nhận Action này và chuyển đến các bộ phận xử lý tương ứng. Hãy coi đây là "trung tâm điều khiển" đảm bảo mọi thứ được xử lý theo đúng trình tự.
  3. Store (Kho lưu trữ):
    • Effect (Xử lý bất đồng bộ): Sẽ bắt lấy Action này để thực hiện các tác vụ "ngoại lai" như gọi API lưu vào Database (giả lập 1 giây).
    • Reducer: Sau khi Effect hoàn tất, Reducer sẽ nhận kết quả và tính toán để cập nhật lại danh sách sách trong State.
  4. View (Giao diện): Component nhận được State mới từ Store và tự động vẽ lại (Re-render). Người dùng sẽ thấy cuốn sách mới xuất hiện trên danh sách mà bạn không cần phải viết code JS để "chọc" vào DOM.

Tại sao cách làm này lại ưu việt?
Bởi vì dữ liệu chỉ chảy theo một hướng duy nhất. Bạn sẽ không bao giờ gặp phải tình trạng State bị thay đổi "lén lút" từ một Component xa lạ nào đó. Mọi thay đổi đều để lại dấu vết thông qua các Action, giúp việc Debug trở nên cực kỳ nhẹ nhàng.

State

Là một đối tượng (thường là record) chứa toàn bộ dữ liệu hiện tại của ứng dụng hoặc một tính năng.

Đặc điểm: Bất biến (Immutable). Bạn không thay đổi State, bạn thay thế nó bằng một phiên bản mới.

[FeatureState]
public record BookState
{
    public List<string> Books { get; init; } = new() 
    { 
        "Clean Code", 
        "Design Patterns", 
        "Sử ký Tư Mã Thiên",
        "Đông Châu liệt quốc"
    };
    public bool IsLoading { get; init; }
}

Mẹo nâng cao: Tùy biến khởi tạo State với Class Feature

Thông thường, [FeatureState] là đủ. Nhưng nếu bạn cần thực hiện logic phức tạp (như Log, gọi Factory, hoặc đặt tên định danh riêng cho Store) ngay khi ứng dụng khởi chạy, hãy kế thừa lớp Feature<TState>:

public class UIFeature : Feature<UIState>
{
    public override string GetName() => "UI_Module";

    protected override UIState GetInitialState()
    {
        // Bạn có thể thực hiện logic chuẩn bị dữ liệu tại đây
        return UIState.CreateDefaultConfig();
    }
}
Lưu ý: Khi dùng cách này, Fluxor vẫn sẽ tự động nhận diện thông qua hàm ScanAssemblies trong Program.cs, bạn không cần đăng ký thủ công thêm gì cả!

Reducer

Vai trò: Là một hàm chịu trách nhiệm tạo ra State mới dựa trên State cũ và một Action.

Nguyên tắc vàng: Phải là Pure Function (Hàm thuần túy).

  • Nó không được gọi API, không được lấy thời gian thực (DateTime.Now), không được sinh số ngẫu nhiên.
  • Với cùng một đầu vào (State cũ + Action), nó luôn phải trả về cùng một kết quả (State mới).
Trách nhiệm: Chỉ thực hiện các logic đồng bộ và nhanh chóng (như thêm item vào list, đổi flag loading từ true sang false).
public class BookReducer
{
    [ReducerMethod]
    public static BookState ReduceAddBookAction(BookState state, AddBookAction action)
    {
        return state with { IsLoading = true };
    }

    [ReducerMethod]
    public static BookState ReduceAddBookSuccessAction(BookState state, AddBookSuccessAction action)
    {
        return state with
        {
            IsLoading = false,
            Books = state.Books.Append(action.Title).ToList()
        };
    }

    [ReducerMethod]
    public static BookState ReduceRemoveBookAction(BookState state, RemoveBookAction action)
    {
        return state with { Books = state.Books.Where(book => book != action.Title).ToList() };
    }
}

Effect - "Kẻ gây ra các tác động ngoại lai" (Side Effects)

Vai trò: Xử lý các tác vụ nằm ngoài tầm kiểm soát của Reducer.

Khi nào dùng Effect?

  • Gọi HTTP Client để lấy dữ liệu từ Server.
  • Truy cập Database hoặc LocalStorage.
  • Thực hiện các thuật toán phức tạp (như chạy mô hình ML.NET cho Personal Stock VN).

Luồng hoạt động: Effect lắng nghe một Action -> Thực hiện logic nặng/bất đồng bộ -> Sau khi xong, nó sẽ Dispatch một Action khác (thường là SuccessAction) để Reducer cập nhật kết quả vào State.

public class BookEffect
{
    [EffectMethod]
    public async Task HandleAddBookAction(AddBookAction action, IDispatcher dispatcher)
    {
        await Task.Delay(1000);

        dispatcher.Dispatch(new AddBookSuccessAction(action.Title));
    }
}

4. Tổ chức mã nguồn theo Features

Thay vì gom nhóm theo loại file (State, Effect...), chúng ta nên tổ chức theo Features (Tính năng). Cách tiếp cận này giúp code gọn gàng, dễ tìm kiếm và mở rộng khi dự án phình to.


Solution/
└── YourProject.Web/
    └── Store/
        └── BookUseCase/
            ├── Actions/
            │   ├── AddBookAction.cs
            │   ├── AddBookSuccessAction.cs
            │   └── RemoveBookAction.cs
            ├── BookState.cs      <-- Định nghĩa dữ liệu
            ├── BookReducers.cs   <-- Logic cập nhật UI đồng bộ
            └── BookEffects.cs    <-- Xử lý API, Async, logic nặng
    

Tại sao nên chọn cách này?

  • Dễ bảo trì: Toàn bộ logic của một tính năng nằm trọn trong một thư mục.
  • Ít xung đột: Khi nhiều người cùng làm các tính năng khác nhau, khả năng bị trùng file hay lỗi Merge sẽ thấp hơn.
  • Tương đồng Backend: Rất gần gũi với kiến trúc Vertical Slice Architecture phổ biến trong .NET Core.

5. Kết bài

Fluxor có thể hơi rườm rà lúc setup ban đầu, nhưng khi ứng dụng của bạn vượt quá 10 trang với hàng chục component lồng nhau, nó sẽ là "cứu cánh" giúp code của bạn luôn sạch sẽ và dễ bảo trì.

Chúc các bạn thành công với dự án Blazor Server của mình!

Nhận xét

Bài đăng phổ biến từ blog này

[ASP.NET MVC] Authentication và Authorize

Một trong những vấn đề bảo mật cơ bản nhất là đảm bảo những người dùng hợp lệ truy cập vào hệ thống. ASP.NET đưa ra 2 khái niệm: Authentication và Authorize Authentication xác nhận bạn là ai. Ví dụ: Bạn có thể đăng nhập vào hệ thống bằng username và password hoặc bằng ssh. Authorization xác nhận những gì bạn có thể làm. Ví dụ: Bạn được phép truy cập vào website, đăng thông tin lên diễn đàn nhưng bạn không được phép truy cập vào trang mod và admin.

ASP.NET MVC: Cơ bản về Validation

Validation (chứng thực) là một tính năng quan trọng trong ASP.NET MVC và được phát triển trong một thời gian dài. Validation vắng mặt trong phiên bản đầu tiên của asp.net mvc và thật khó để tích hợp 1 framework validation của một bên thứ 3 vì không có khả năng mở rộng. ASP.NET MVC2 đã hỗ trợ framework validation do Microsoft phát triển, tên là Data Annotations. Và trong phiên bản 3, framework validation đã hỗ trợ tốt hơn việc xác thực phía máy khách, và đây là một xu hướng của việc phát triển ứng dụng web ngày nay.

Tổng hợp một số kiến thức lập trình về Amibroker

Giới thiệu về Amibroker Amibroker theo developer Tomasz Janeczko được xây dựng dựa trên ngôn ngữ C. Vì vậy bộ code Amibroker Formula Language sử dụng có syntax khá tương đồng với C, ví dụ như câu lệnh #include để import hay cách gói các object, hàm trong các block {} và kết thúc câu lệnh bằng dấu “;”. AFL trong Amibroker là ngôn ngữ xử lý mảng (an array processing language). Nó hoạt động dựa trên các mảng (các dòng/vector) số liệu, khá giống với cách hoạt động của spreadsheet trên excel.