Package Exports
- ngx-statewise
- ngx-statewise/package.json
Readme
ngx-statewise
A lightweight and intuitive state management library for Angular.
Table of Contents
- Description
- Features
- Installation
- Key Concepts
- Usage Example
- Benefits
- When to Use ngx-statewise
- Contributing
- License
Description
ngx-statewise is a state management solution for Angular applications, offering a lighter and easier-to-use alternative to libraries like NgRx or NGXS, while maintaining a clear and predictable architecture for managing your application's state.
Features
- 🔄 Flexible state management (supports Angular signals and regular properties)
- 🧩 Modular and maintainable architecture
- 📦 Predictable state updates
- 🚀 Effects for handling asynchronous operations
- 🔍 Easy to debug
Installation
npm install ngx-statewise --saveKey Concepts
1. States
States represent the current state of your application or a specific feature. They can be defined using Angular signals or regular properties.
@Injectable({
providedIn: 'root',
})
export class AuthStates {
public user = signal<User | null>(null);
public accessToken: string | null = null; // Regular property
public isLoggedIn = signal(false);
public isLoading = signal(false);
public asError = signal(false);
}2. Actions
Actions are events that trigger state changes. They can be defined individually or as a group. You can include any events you want in an action group - you're not limited to the request-success-failure pattern.
Action Group
When using defineActionsGroupe, action types are automatically created by combining the source and event name. For example, with a source of 'LOGIN', events like 'request' will become 'LOGIN_REQUEST'.
export const loginActions = defineActionsGroupe({
source: 'LOGIN',
events: {
request: payload<LoginSubmit>(), // Becomes LOGIN_REQUEST
success: payload<LoginResponses>(), // Becomes LOGIN_SUCCESS
failure: emptyPayload, // Becomes LOGIN_FAILURE
cancel: emptyPayload, // Becomes LOGIN_CANCEL
retry: payload<number>(), // Becomes LOGIN_RETRY
},
});Single Action
For single actions defined with defineSingleAction, the type will always be suffixed with '_ACTION'. For example, 'LOGOUT' becomes 'LOGOUT_ACTION'.
export const logoutAction = defineSingleAction('LOGOUT', emptyPayload); // Becomes LOGOUT_ACTION
export const selectItemAction = defineSingleAction('SELECT_ITEM', payload<number>()); // Becomes SELECT_ITEM_ACTION3. Updators
Updators are responsible for updating the state in response to actions. The action type key used in the updator must exactly match the type generated by the action definition. Updators should focus solely on modifying state data - any side effects or operations not directly related to state updates should be placed in Effects.
@Injectable({
providedIn: 'root',
})
export class AuthUpdator implements IUpdator<AuthStates> {
public readonly state = inject(AuthStates);
public readonly updators: UpdatorRegistry<AuthStates> = {
LOGIN_REQUEST: (state) => {
// Only modify state data, no side effects here
state.isLoading.set(true);
state.asError.set(false);
},
LOGIN_SUCCESS: (state, payload: LoginResponses) => {
state.user.set({
userId: payload.userId,
userName: payload.userName,
email: payload.email,
});
state.isLoggedIn.set(true);
state.isLoading.set(false);
},
LOGIN_FAILURE: (state) => {
state.user.set(null);
state.isLoggedIn.set(false);
state.asError.set(true);
state.isLoading.set(false);
},
LOGOUT_ACTION: (state) => {
state.user.set(null);
state.isLoggedIn.set(false);
state.asError.set(false);
state.isLoading.set(false);
},
};
}4. Effects
Effects handle asynchronous operations like API calls. They are created with the createEffect utility function and are tied to specific actions. Effects can return other actions to trigger updators or other effects, creating a chain of operations. Be careful not to return the input action to avoid infinite loops.
@Injectable({
providedIn: 'root',
})
export class AuthEffects {
private readonly authRepository = inject(AuthRepositoryService);
private readonly authTokenService = inject(AuthTokenService);
private readonly router = inject(Router);
public readonly loginEffect = createEffect(
loginActions.request, // Triggered by the request action
(payload) => {
return this.authRepository.login(payload).pipe(
map((res) => {
// On success, return the success action with payload
this.router.navigate(['/']);
this.authTokenService.setAccessToken(res.body?.accessToken!);
return loginActions.success(res.body!); // Success action
}),
catchError(() => of(loginActions.failure())) // Failure action on error
// Don't return loginActions.request() here or you'll create an infinite loop!
);
}
);
public readonly logoutEffect = createEffect(
logoutAction.action,
() => {
this.router.navigate(['/']);
return EMPTY; // No additional action needed
}
);
}Registering Effects
Effect classes must be registered using provideEffects in your app config. Without this registration, the effects won't be initialized and nothing will happen when actions are dispatched.
export const appConfig: ApplicationConfig = {
providers: [
provideEffects([
AuthEffects,
UserEffects,
// Add all your effect classes here
]),
// other providers
]
};This approach ensures all your effects are properly registered and instantiated when your application starts, allowing them to listen for actions and perform side effects.
5. Managers
Managers expose the public APIs for interacting with the state. They expose state data (signals or regular properties) and provide methods that trigger actions, creating a clean interface for components to use.
@Injectable({
providedIn: 'root',
})
export class AuthManager implements IAuthManager {
private readonly authStates = inject(AuthStates);
private readonly authEffects = inject(AuthEffects);
private readonly authUpdator = inject(AuthUpdator);
// Expose states for components to access
public readonly user = this.authStates.user;
public readonly isLoggedIn = this.authStates.isLoggedIn;
public readonly isLoading = this.authStates.isLoading;
public readonly asError = this.authStates.asError;
// Define methods that trigger actions
public login(credential: LoginSubmit): void {
dispatch(
loginActions.request(credential),
this.authUpdator
);
}
public logout() {
dispatch(
logoutAction.action(),
this.authUpdator
);
}
}Usage Example
Defining States
@Injectable({
providedIn: 'root',
})
export class TodoStates {
public todos = signal<Todo[]>([]);
public isLoading = signal(false);
public error = signal<string | null>(null);
public selectedTodoId: number | null = null; // Regular property
}Defining Actions
export const todoActions = defineActionsGroupe({
source: 'TODO',
events: {
load: emptyPayload, // Becomes TODO_LOAD
loadSuccess: payload<Todo[]>(), // Becomes TODO_LOAD_SUCCESS
loadFailure: payload<string>(), // Becomes TODO_LOAD_FAILURE
add: payload<Todo>(), // Becomes TODO_ADD
remove: payload<number>(), // Becomes TODO_REMOVE
select: payload<number>(), // Becomes TODO_SELECT
},
});Defining Updators
@Injectable({
providedIn: 'root',
})
export class TodoUpdator implements IUpdator<TodoStates> {
public readonly state = inject(TodoStates);
public readonly updators: UpdatorRegistry<TodoStates> = {
TODO_LOAD: (state) => {
state.isLoading.set(true);
state.error.set(null);
},
TODO_LOAD_SUCCESS: (state, payload: Todo[]) => {
state.todos.set(payload);
state.isLoading.set(false);
},
TODO_LOAD_FAILURE: (state, payload: string) => {
state.error.set(payload);
state.isLoading.set(false);
},
TODO_ADD: (state, payload: Todo) => {
const currentTodos = state.todos();
state.todos.set([...currentTodos, payload]);
},
TODO_REMOVE: (state, payload: number) => {
const currentTodos = state.todos();
state.todos.set(currentTodos.filter(todo => todo.id !== payload));
},
TODO_SELECT: (state, payload: number) => {
// Example of updating a regular property
state.selectedTodoId = payload;
},
};
}Creating Effects
@Injectable({
providedIn: 'root',
})
export class TodoEffects {
private readonly todoService = inject(TodoService);
public readonly loadTodosEffect = createEffect(
todoActions.load,
() => {
return this.todoService.getTodos().pipe(
map(todos => todoActions.loadSuccess(todos)),
catchError(error => of(todoActions.loadFailure(error.message)))
);
}
);
public readonly addTodoEffect = createEffect(
todoActions.add,
(todo) => {
return this.todoService.addTodo(todo).pipe(
map(() => todoActions.load()), // Chain to load action after adding
catchError(error => of(todoActions.loadFailure(error.message)))
);
}
);
}Usage in Components
@Component({
selector: 'app-todo-list',
template: `
@if (isLoading()) {
<div>Loading...</div>
}
@if (error()) {
<div>{{ error() }}</div>
}
<ul>
@for (todo of todos(); track todo.id) {
<li [class.selected]="todo.id === todoManager.selectedTodoId"
(click)="selectTodo(todo.id)">
{{ todo.title }}
<button (click)="removeTodo(todo.id)">Delete</button>
</li>
}
</ul>
<button (click)="loadTodos()">Refresh</button>
`,
})
export class TodoListComponent {
private todoManager = inject(TodoManager);
public todos = this.todoManager.todos;
public isLoading = this.todoManager.isLoading;
public error = this.todoManager.error;
loadTodos(): void {
this.todoManager.loadTodos();
}
removeTodo(id: number): void {
this.todoManager.removeTodo(id);
}
selectTodo(id: number): void {
this.todoManager.selectTodo(id);
}
}Benefits
- Simplicity: Simpler and more intuitive API than NgRx or NGXS
- Performance: Optional use of Angular signals for optimized change detection
- Modularity: Clear organization of states, actions, and effects
- Testability: Architecture that facilitates unit testing
- Flexibility: Works with both signals and regular properties, allowing for gradual adoption
When to Use ngx-statewise
- Medium to large Angular applications
- Applications with complex state management needs
- Teams looking for a balance between structure and simplicity
- Projects that want to leverage Angular signals but need more structure
Contributing
Contributions are welcome! Feel free to open issues or submit pull requests on GitHub.
License
GPL v3