Telerik blogs

Last time, we looked at which state management method to choose for your Angular app. This time, we’ll explore this in a real app so you can see why different solutions work better for different cases.

It doesn’t matter if you are working with Angular 4, 15, 20 or the latest version 22. Every time I start a new project, I face the same big question: “Where should I put this data?”

I learned this through many challenges. Often you start small and think a few variables are enough. But the app grows fast, and suddenly you are struggling with too many inputs, outputs and “prop drilling.” Based on my experience, state management is about setting the rules for how data flows in your app.

Whether it is a tiny prototype or a large enterprise dashboard, the choices you make today will make your life easier when requirements change later. The tools have evolved, from complex RxJS streams to the simplicity of Signals, but the goal is the same: keeping your UI and data in sync without making it too difficult.

Think of this as a carpenter’s workshop. Different jobs need different tools. We explored this decision in detail in our guide: Decision Guide for Angular State Management: Signals, Services, SignalStore or NgRx.

Today, we are going to learn how to build a real accounting dashboard using the modern patterns of Angular 22 with UI components from the Progress Kendo UI for Angular library. We will start simple and add complexity only when the logic needs it. By the end, you will have a clear plan to decide what goes where, so you can build apps that stay clean as they grow.

Let’s make this work in a real project.

Follow along: All the code for this dashboard is available on GitHub. Feel free to clone it and experiment while you read!

Setting Up the Project

First, let’s create our accounting dashboard app. Open your terminal and run:

ng new accounting-dashboard --standalone --style=scss

Add the libraries we will use across the article. Since Kendo UI components need internationalization and animations, we will also include those packages:

npm install @progress/kendo-angular-grid @progress/kendo-angular-dropdowns @progress/kendo-angular-dateinputs @progress/kendo-angular-buttons @progress/kendo-angular-inputs @progress/kendo-theme-default
npm install @ngrx/signals @ngrx/store @ngrx/effects @ngrx/entity @ngrx/operators @ngrx/signals/events
npm install @angular/localize @angular/animations

Import the Kendo UI theme in src/styles.scss:

@import "@progress/kendo-theme-default/dist/all.css";

Configuring Polyfills

Kendo UI relies on Angular’s localization. Open your angular.json and add @angular/localize/init to the polyfills array of your project:

"polyfills": ["@angular/localize/init"],

Now we have a project ready to go. Each section below builds on the same accounting theme: journal entries, chart of accounts and financial periods.

Project Structure

Here is the folder layout we will build. Create each file as we go through the sections:

src/
  app/
    journal-list/
      journal-list.component.ts
    journal-entry/
      journal-entry.component.ts
    ledger-view/
      ledger-view.component.ts
    account.service.ts
    journal.store.ts
    ledger.state.ts
    period.actions.ts
    period.effects.ts
    app.component.ts
    app.config.ts

We need to configure the app to support HTTP, animations, and modern features. In Angular 22, provideHttpClient() uses the Fetch API by default. Update app.config.ts:

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideAnimations()
  ]
};

The app.component.ts connects all pieces. We will update it as we add each section:

import { Component } from '@angular/core';
import { JournalListComponent } from './journal-list/journal-list.component';
import { JournalEntryComponent } from './journal-entry/journal-entry.component';
import { LedgerViewComponent } from './ledger-view/ledger-view.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [JournalListComponent, JournalEntryComponent, LedgerViewComponent],
  template: `
    <h1>Accounting Dashboard</h1>
    <div style="display: flex; gap: 20px;">
      <app-journal-entry />
      <app-journal-list />
      <app-ledger-view />
    </div>
  `
})
export class AppComponent {
  title = 'Accounting Dashboard';
}

So far we created the project. Now let’s understand what we are solving.

What Problem Does State Management Solve?

Think of your app as a whiteboard. Many components write and read data on it. Without clear rules, one part of the app might delete data that another part needs. Or, two parts might try to change the exact same data at the same time. Using state management is simply the set of rules for who can read, who can write and how everyone stays updated.

As we talked about in our previous Decision Guide for Angular State Management, an app has different types of state. Some state is very simple and only belongs to one component, like a search box. Other state is global and shared across the whole app, like opening or closing a financial month in our accounting app.

Because these types of data are so different, we cannot manage them all the same way. We need to pick the right tool for each job.

Let’s start with the simplest one.

Local State with Signals

The first rule of Angular state management: start with the simplest solution.

What problem does this solve for us? When data only lives in one component, why add a complex library?

In Angular 22, OnPush is the default way components check for changes. This means components only update when a signal changes or a new input arrives. This makes signal() a great choice for local state: it is fast, simple and updates only the parts of the app that changed.

In our accounting dashboard, imagine a list with a search filter. The search text belongs to this component alone. No other component needs it.

Note: To keep this guide practical and visual, we will include some sample data in our examples. This allows you to see the Kendo UI components in action immediately without needing a backend server.

import { Component, signal, computed } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { KENDO_TEXTBOX } from '@progress/kendo-angular-inputs';

interface JournalEntry {
  id: number;
  date: string;
  description: string;
  total: number;
}

@Component({
  selector: 'app-journal-list',
  standalone: true,
  imports: [CurrencyPipe, KENDO_TEXTBOX],
  template: `
    <kendo-textbox
      [value]="searchTerm()"
      (valueChange)="searchTerm.set($event)"
      placeholder="Search entries..."
      [clearButton]="true"
    />

    <p>Showing {{ filteredCount() }} of {{ entries().length }} entries</p>

    @for (entry of filteredEntries(); track entry.id) {
      <div>
        {{ entry.date }} - {{ entry.description }}: {{ entry.total | currency }}
      </div>
    } @empty {
      <p>No entries found.</p>
    }
  `
})
export class JournalListComponent {
  readonly entries = signal<JournalEntry[]>([
    { id: 1, date: '2026-07-01', description: 'Monthly Subscription Sale', total: 150.00 },
    { id: 2, date: '2026-07-02', description: 'Office Supplies', total: 45.50 },
    { id: 3, date: '2026-07-05', description: 'Consulting Service', total: 1200.00 }
  ]);
  readonly searchTerm = signal('');

  readonly filteredEntries = computed(() =>
    this.entries().filter(e =>
      e.description.toLowerCase().includes(this.searchTerm().toLowerCase())
    )
  );
  readonly filteredCount = computed(() => this.filteredEntries().length);
}

Notice the @for control flow syntax and the @empty block. No *ngFor here—this is modern Angular.

Also notice: no imports from NgRx, no actions, no reducers. Just two signal() calls and a computed(). That is it.

The <kendo-textbox> comes from Kendo UI and gives us a styled input with zero CSS work.

Tip: If you want to avoid filtering on every keystroke, Angular 22 offers a new (experimental) debounced() function. It creates a debounced version of a signal that you can consume in your computed() logic.

When to use this approach: State that only one component and its children need. Examples: search filters, toggle visibility, form input values, selected tab index.

So far we have local state, but what happens when two unrelated components need the same data? Let’s move to shared state.

Shared State with Services

When many components need the same data, a service with Signals is the right tool.

You could pass data through inputs and outputs. But that gets difficult when components are in different parts of the app.

Angular 22 introduces the @Service decorator. It is a new, shorter way to create services that are available everywhere in your app. It makes it easy to use the inject() function, which keeps our code clean and reactive. This is a great but often forgotten pattern in Angular. Seriously.

import { Service, computed, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';

export interface Account {
  code: string;
  name: string;
  type: 'asset' | 'liability' | 'equity' | 'revenue' | 'expense';
}

@Service()
export class AccountService {
  private readonly _accountsRes = httpResource<Account[]>(() => '/api/accounts');
  private readonly _localAccounts = signal<Account[]>([
    { code: '101', name: 'Cash', type: 'asset' },
    { code: '102', name: 'Accounts Receivable', type: 'asset' },
    { code: '401', name: 'Sales Revenue', type: 'revenue' },
    { code: '501', name: 'Operating Expenses', type: 'expense' }
  ]);
  private readonly _selectedCode = signal<string | null>(null);

  readonly accounts = computed(() => [
    ...(this._accountsRes.value() ?? []),
    ...this._localAccounts()
  ]);
  readonly isLoading = this._accountsRes.isLoading;
  readonly selectedCode = this._selectedCode.asReadonly();

  readonly selectedAccount = computed(() =>
    this.accounts().find(a => a.code === this._selectedCode())
  );

  readonly revenueAccounts = computed(() =>
    this.accounts().filter(a => a.type === 'revenue')
  );

  selectAccount(code: string) {
    this._selectedCode.set(code);
  }

  addAccount(account: Account) {
    this._localAccounts.update(list => [...list, account]);
  }
}

Now any component can inject AccountService and get the same data. The journal entry form uses a Kendo UI DropDownList to select accounts:

import { Component, inject } from '@angular/core';
import { KENDO_DROPDOWNLIST } from '@progress/kendo-angular-dropdowns';
import { AccountService } from '../account.service';

@Component({
  selector: 'app-journal-entry',
  standalone: true,
  imports: [KENDO_DROPDOWNLIST],
  template: `
    <h3>New Journal Entry</h3>

    <label>Revenue Account:</label>
    <kendo-dropdownlist
      [data]="revenueAccounts()"
      textField="name"
      valueField="code"
      [valuePrimitive]="true"
      [defaultItem]="{ code: '', name: 'Select account...' }"
      (valueChange)="onSelectAccount($event)"
    >
    </kendo-dropdownlist>

    @if (selectedAccount(); as acct) {
      <p>Selected: {{ acct.name }} - {{ acct.type }}</p>
    }
  `
})
export class JournalEntryComponent {
  private readonly accountService = inject(AccountService);

  readonly revenueAccounts = this.accountService.revenueAccounts;
  readonly selectedAccount = this.accountService.selectedAccount;

  onSelectAccount(code: string) {
    if (code) {
      this.accountService.selectAccount(code);
    }
  }
}

The <kendo-dropdownlist> handles the UI: search within the dropdown, keyboard navigation, disabled state. We get this for free.

Tip: For heavy services that are only needed occasionally (like generating a PDF report), use injectAsync(). It allows you to lazy-load the service instance only when it is actually called, reducing your initial bundle size.

When to use this approach: State shared by 2 to 10 components within the same feature. Examples: chart of accounts, user preferences for a module, selected filters shared between list and detail views.

When to escalate: You need strict conventions, computed state with complex rules or DevTools for debugging. That is SignalStore territory.

Signals or BehaviorSubject?

I often ask myself: should I use signal() or BehaviorSubject?

In the Decision Guide for Angular State Management post, I compared both options. My advice is simple. For new code, Signals are the best choice. They are very easy to read, and they work perfectly with Angular. I only use RxJS (BehaviorSubject) for complex tasks. For example, I use it to delay a search (debounce) or to stop a web request. For normal data, I just use Signals.

So far, we have shared data using a simple service. Now, let’s add some rules and structure.

NgRx SignalStore

SignalStore gives you structure without all the extra code of classic NgRx. It is the recommended state management solution for Angular apps (NgRx team, v19+).

A simple service works well for basic state. But as your feature grows, you want rules: computed values, loading states and a consistent way to change data. Without these, team members might name methods differently or change data in confusing ways.

Think of SignalStore as the middle ground between a simple service and a complex Redux store.

In our accounting app, journal entries have rules: debits and credits must balance. A JournalStore can check this automatically with computed values.

import { signalStore, withState, withComputed, withMethods, patchState, withHooks } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { tapResponse } from '@ngrx/operators';
import { pipe, switchMap, tap } from 'rxjs';

export interface JournalLine {
  accountCode: string;
  debit: number;
  credit: number;
}

export interface JournalEntry {
  id: number;
  date: string;
  description: string;
  lines: JournalLine[];
}

type JournalState = {
  entries: JournalEntry[];
  isLoading: boolean;
};

const initialState: JournalState = {
  entries: [
    {
      id: 1,
      date: '2026-07-01',
      description: 'Initial Opening Balance',
      lines: [
        { accountCode: '101', debit: 5000, credit: 0 },
        { accountCode: '301', debit: 0, credit: 5000 }
      ]
    },
    {
      id: 2,
      date: '2026-07-08',
      description: 'Service Revenue Recognition',
      lines: [
        { accountCode: '102', debit: 1200, credit: 0 },
        { accountCode: '401', debit: 0, credit: 1200 }
      ]
    }
  ],
  isLoading: false
};

function totalDebits(entries: JournalEntry[]): number {
  return entries.reduce((sum, e) =>
    sum + e.lines.reduce((s, l) => s + l.debit, 0), 0
  );
}

function totalCredits(entries: JournalEntry[]): number {
  return entries.reduce((sum, e) =>
    sum + e.lines.reduce((s, l) => s + l.credit, 0), 0
  );
}

export const JournalStore = signalStore(
  { providedIn: 'root' },

  withState(initialState),

  withComputed((store) => ({
    entryCount: computed(() => store.entries().length),
    totalDebits: computed(() => totalDebits(store.entries())),
    totalCredits: computed(() => totalCredits(store.entries())),
    isBalanced: computed(() => totalDebits(store.entries()) === totalCredits(store.entries())),
  })),

  withMethods((store, http = inject(HttpClient)) => ({
    loadEntries: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { isLoading: true })),
        switchMap(() =>
          http.get<JournalEntry[]>('/api/journal-entries').pipe(
            tapResponse({
              next: (entries) => patchState(store, { entries, isLoading: false }),
              error: () => patchState(store, { isLoading: false }),
            })
          )
        )
      )
    ),

    addEntry(entry: JournalEntry) {
      patchState(store, { entries: [...store.entries(), entry] });
    },
  })),

  withHooks({
    onInit(store) {
      store.loadEntries();
    },
  })
);

Tip: For simple data loading, prefer async/await with patchState or Angular’s httpResource (v19+). Reserve rxMethod for complex reactive flows like cancellation or dependent requests and with httpResource, the loading state is managed automatically:

import { httpResource } from '@angular/common/http';

// in a component or service:
readonly entriesResource = httpResource<JournalEntry[]>(() => '/api/journal-entries');
readonly entries = computed(() => this.entriesResource.value() ?? []);
readonly isLoading = this.entriesResource.isLoading;

Now a component can use the store and display the results in a Kendo UI for Angular Grid:

import { Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { KENDO_GRID } from '@progress/kendo-angular-grid';
import { JournalStore, JournalLine } from '../journal.store';

@Component({
  selector: 'app-ledger-view',
  standalone: true,
  imports: [CurrencyPipe, KENDO_GRID],
  template: `
    <h3>General Ledger</h3>

    @if (store.isLoading()) {
      <p>Loading entries...</p>
    } @else {
      <kendo-grid [data]="store.entries()">
        <kendo-grid-column field="date" title="Date" [width]="120">
        </kendo-grid-column>
        <kendo-grid-column field="description" title="Description" [width]="200">
        </kendo-grid-column>
        <kendo-grid-column title="Debit" [width]="120">
          <ng-template kendoGridCellTemplate let-dataItem>
            {{ getDebitTotal(dataItem.lines) | currency }}
          </ng-template>
        </kendo-grid-column>
        <kendo-grid-column title="Credit" [width]="120">
          <ng-template kendoGridCellTemplate let-dataItem>
            {{ getCreditTotal(dataItem.lines) | currency }}
          </ng-template>
        </kendo-grid-column>
      </kendo-grid>
    }

    @if (!store.isBalanced()) {
      <div style="color: red; margin-top: 8px;">
        Warning: Journal entries are not balanced!
      </div>
    }
  `
})
export class LedgerViewComponent {
  readonly store = inject(JournalStore);

  getDebitTotal(lines: JournalLine[]) {
    return lines.reduce((s, l) => s + l.debit, 0);
  }

  getCreditTotal(lines: JournalLine[]) {
    return lines.reduce((s, l) => s + l.credit, 0);
  }
}

The <kendo-grid> gives us sorting, paging, and column resize out of the box. And the isBalanced() computed signal shows a warning instantly when numbers do not match.

Adding More SignalStore Features

SignalStore has more tools to help you write code faster without leaving the signal model.

withProps adds injected services or static properties to the store. In our case, we can inject the AccountService from the previous section:

import { withProps } from '@ngrx/signals';
import { AccountService } from './account.service';

export const JournalStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(/* ... */),
  withProps(() => ({
    accountService: inject(AccountService),
  })),
  withMethods(/* ... */)
);

withHooks provides onInit and onDestroy lifecycle hooks. As shown in our main example, this is the modern way to load data when the store is first used, keeping your components clean and free of lifecycle interfaces:

import { withHooks } from '@ngrx/signals';

export const JournalStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(/* ... */),
  withMethods((store) => ({
    loadEntries: rxMethod<void>(/* ... */),
  })),
  withHooks({
    onInit(store) {
      store.loadEntries();
    }
  })
);

Entity Management with @ngrx/signals/entities gives us withEntities(), setAllEntities, addEntity and more. For our accounts, this enables efficient lookups by code:

import { withEntities, setAllEntities, addEntity, entityConfig } from '@ngrx/signals/entities';
import { signalStore, type } from '@ngrx/signals';
import { Account } from './account.service';

const accountConfig = entityConfig({
  entity: type<Account>(),
  collection: 'accounts',
  selectId: (account) => account.code,
});

export const AccountStore = signalStore(
  { providedIn: 'root' },
  withEntities(accountConfig),
  withMethods((store) => ({
    loadAccounts(accounts: Account[]) {
      patchState(store, setAllEntities(accounts, accountConfig));
    },
    addAccount(account: Account) {
      patchState(store, addEntity(account, accountConfig));
    },
  }))
);

Events Plugin: Communication Between Stores

What happens when two stores need to talk to each other? For example, when you close a month, both the journal store and the ledger store need to stop allowing changes.

The Events Plugin (@ngrx/signals/events) solves this without making the stores depend on each other. Think of it as a communication system for your stores.

import { eventGroup } from '@ngrx/signals/events';

export const accountingEvents = eventGroup({
  source: 'Accounting',
  events: {
    entryPosted: (entryId: number, total: number) => ({ entryId, total }),
    periodClosed: (periodId: number) => ({ periodId }),
    periodReopened: (periodId: number) => ({ periodId }),
  },
});

Any store can react to these events using on and withReducer:

import { on, withReducer } from '@ngrx/signals/events';

export const JournalStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withReducer(
    on(accountingEvents.periodClosed, (state) => ({
      ...state,
      isReadOnly: true,
    })),
    on(accountingEvents.periodReopened, (state) => ({
      ...state,
      isReadOnly: false,
    })),
  )
);

This approach is great when multiple features need to stay updated without importing each other’s stores.

So far we covered SignalStore. Now let’s look at the classic NgRx Store and when it still makes sense.

Classic NgRx Store

The classic NgRx Store (actions, reducers, selectors, effects) is best for very complex data. SignalStore works for most features, but some situations need the full Redux pattern.

This approach is great when:

  • Many features share a large amount of connected data.
  • Your team needs Redux DevTools for advanced debugging.
  • You need a clear record of every state change.
  • Complex async workflows involve multiple services.

In our accounting app, managing monthly periods fits here. Opening and closing a month affects entries, the ledger and reports. Getting this wrong can cause data errors.

Entities with @ngrx/entity

For normalized data like the general ledger, @ngrx/entity provides createEntityAdapter with built-in CRUD:

import { createEntityAdapter, EntityState } from '@ngrx/entity';

export interface LedgerEntry {
  id: number;
  accountCode: string;
  debit: number;
  credit: number;
  periodId: number;
}

export const ledgerAdapter = createEntityAdapter<LedgerEntry>({
  selectId: (entry) => entry.id,
  sortComparer: (a, b) => a.id - b.id,
});

export interface LedgerState extends EntityState<LedgerEntry> {
  isLoading: boolean;
  selectedPeriodId: number | null;
}

export const initialState: LedgerState = ledgerAdapter.getInitialState({
  isLoading: false,
  selectedPeriodId: null,
});

The reducer uses adapter methods directly:

export const ledgerReducer = createReducer(
  initialState,
  on(LedgerActions.loadSuccess, (state, { entries }) =>
    ledgerAdapter.setAll(entries, { ...state, isLoading: false })
  ),
  on(LedgerActions.addEntry, (state, { entry }) =>
    ledgerAdapter.addOne(entry, state)
  ),
);

Actions and Effects for Period Management

For financial periods, each action is a named event with a clear meaning:

import { createActionGroup, props } from '@ngrx/store';

export const PeriodActions = createActionGroup({
  source: 'Financial Period',
  events: {
    'Open Period': props<{ periodId: number }>(),
    'Close Period': props<{ periodId: number }>(),
    'Period Closed': props<{ periodId: number; closedAt: string }>(),
    'Period Error': props<{ error: string }>(),
  },
});

Effects handle the async workflow:

import { createEffect, Actions, ofType } from '@ngrx/effects';
import { switchMap, map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';

export const closePeriod$ = createEffect(
  (actions$ = inject(Actions), ledgerService = inject(LedgerService)) =>
    actions$.pipe(
      ofType(PeriodActions.closePeriod),
      switchMap(({ periodId }) =>
        ledgerService.closePeriod(periodId).pipe(
          map((closedAt) => PeriodActions.periodClosed({ periodId, closedAt })),
          catchError((err) => of(PeriodActions.periodError({ error: err.message })))
        )
      )
    ),
  { functional: true }
);

This is more code than SignalStore. And it should be. The benefit is having a clear history: every time a month is closed, it is recorded as an action with a timestamp. DevTools can show you exactly what happened.

Every tool has pros and cons. Classic NgRx is powerful but needs more code. Use it only for very complex features. For most other things, SignalStore is enough.

Now that we have seen all the options, how do you decide which one to use?

How to Choose the Right Approach

Now that we have seen all the options, how do you decide which one to use? As explained in the Decision Guide, there is no single answer for every case.

The best choice depends on how much you share your data and how complex it is.

Let’s apply those rules to our accounting app. The answer for each part is clear:

  • Search filter: Local Signals. Just a signal('') in the component.
  • Account list: Service with Signals. An AccountService shared between the form and the report.
  • Journal entries: SignalStore. A JournalStore to check balance rules.
  • Closing a month: Classic Store. Full Redux with actions, effects and DevTools.

A team building this app would use all four tools in the same project. This is completely fine. It is just about using the right tool for each job.

A great state management strategy is only complete if you can test it easily.

Testing Each Approach

Testing state management is simpler with Signals. Here is how each approach works:

Testing Local Signals

Signals are synchronous. Set a value, read it, assert:

it('filters entries by description', () => {
  const fixture = TestBed.createComponent(JournalListComponent);
  const component = fixture.componentInstance;
  component.entries.set([{ id: 1, date: '2026-01-01', description: 'Sale', total: 100 }]);
  component.searchTerm.set('sale');
  expect(component.filteredCount()).toBe(1);
});

Testing with Service with Signals

Test the service as an injectable:

it('adds an account to the chart of accounts', () => {
  const service = TestBed.inject(AccountService);
  service.addAccount({ code: '101', name: 'Cash', type: 'asset' });
  expect(service.accounts().length).toBe(1);
});

Testing with SignalStore

Inject the store like a service:

it('computes total debits correctly', () => {
  const store = TestBed.inject(JournalStore);
  store.addEntry({
    id: 1, date: '2026-01-01', description: 'Test', lines: [
      { accountCode: '101', debit: 100, credit: 0 },
      { accountCode: '401', debit: 0, credit: 100 },
    ]
  });
  expect(store.totalDebits()).toBe(100);
  expect(store.totalCredits()).toBe(100);
  expect(store.isBalanced()).toBeTrue();
});

Testing with Classic Store

Use provideMockStore and provideMockActions for isolated tests:

it('closes a period via reducer', () => {
  const result = periodReducer(initialState, PeriodActions.closePeriod({ periodId: 1 }));
  expect(result.periods.find(p => p.id === 1)?.status).toBe('closed');
});

The trend across all approaches is toward simpler tests. Signals let you assert synchronously without fakeAsync or async pipes.

With our testing strategy ready, here is a quick summary.

In modern Angular, I prefer to start simple and use local signals first. If things becomes complex, then I move data to a service only when I need to share it. Signals and services are now the standard choice for most of our data. I only pick complex libraries, like classic NgRx, for hard problems. For example, I use them if I need to track exactly how data changes over time.

But remember, state management is not about strict rules. It is simply about choosing the best tool for your project and your team.

Try Kendo UI for Angular

You can download a free 30-day trial to try out everythign we’ve explored in better detail.

Try Now

Open Challenge

Try these ideas to practice what we learned:

  • Add a Kendo UI Chart that shows the balance trend using computed() signals.
  • Use httpResource in the JournalStore to load data.

Try building one of these. You will see how each approach handles the task differently.

References


About the Author

Dany Paredes

Dany Paredes is a Google Developer Expert on Angular and Progress Champion. He loves sharing content and writing articles about Angular, TypeScript and testing on his blog and on Twitter (@danywalls).

Related Posts

Comments

Comments are disabled in preview mode.