mobx-openapi-stores
v2.0.0
Published
A MobX-based store implementation for OpenAPI generated clients
Readme
MobX OpenAPI Stores
mobx-openapi-stores provides a set of base MobX stores designed to simplify the integration of OpenAPI-generated API clients into your frontend applications. These stores offer structured ways to manage API interactions, loading states, and common data patterns like single entities, collections, and grouped objects. Errors will automatically be processed and logged to the console.
Features
- Less Boilerplate: Less code to write and maintain.
- Type-Safe: Distributive
ApiMethodName/ApiMethodArgshelpers for OpenAPI client unions. - Reactive: Built with MobX for efficient and easy-to-manage state.
- Modular: Distinct store classes for single entities, collections, paginated lists, CRUD, and keyed objects.
- Extensible: Base stores can be easily extended to add application-specific logic.
- Loading State Management: Integrated loading indicators, including
disableLoadingfor prefetch. - Simplified API Calls:
apiCallpreserves generated clientthisbinding. - Error Handling: Errors are processed and logged to the console.
Repository
- Public mirror / npm source metadata: GitHub
- Primary development, MRs, tests, and version tagging run on an internal Git host (not linked here).
- npm publishes use trusted publishing from GitHub Actions (
.github/workflows/publish.yml) onv*tags. Security scanning stays on the internal CI.
Installation
npm install mobx-openapi-stores mobx
# or
yarn add mobx-openapi-stores mobxPeer dependency: MobX 6+ (uses makeObservable).
Core Stores
This package provides the following core store classes:
LoadingStore: Manages a simple boolean loading state.ApiStore: Manages an API client instance and API call flows.SingleStore: Manages a single observable entity.CollectionStore: Manages a collection (array) of observable entities.CrudCollectionStore: ExtendsCollectionStorewith helper methods for common API-driven CRUD operations.PaginationStore: Page cache, prefetch, and list invalidation for paginated endpoints.ObjectStore: Manages a dictionary-like observable object, useful for grouping entities.
1. LoadingStore
Keyed loading refcounts for asynchronous work. isLoading is true while any key is active; isLoadingFor(key) scopes UI to one concern.
Purpose: To be extended by other stores that need to track loading states for asynchronous operations.
Key Members:
isLoading: boolean(computed): True while at least one loading holder is active on any key.isLoadingFor(key: string): True while that key has an active holder.beginLoading(key?)/endLoading(key?)(actions): Increment/decrement a key (default*).setIsLoading(loading: boolean)(action): Absolute update —trueensures loading;falseclears all keys.
Example:
import { LoadingStore } from 'mobx-openapi-stores';
class MyCustomLoadingComponentStore extends LoadingStore {
async performSomeTask() {
this.beginLoading('task');
try {
// ... your async logic ...
await new Promise((resolve) => setTimeout(resolve, 1000));
} finally {
this.endLoading('task');
}
}
}
const store = new MyCustomLoadingComponentStore();
// autorun(() => console.log(store.isLoading, store.isLoadingFor('task')));
// store.performSomeTask();2. ApiStore<TApi, TConfig>
Extends LoadingStore. Manages an instance of your OpenAPI-generated API client and provides a standardized way to make API calls.
Purpose: Base for stores that interact with an API. Handles API client initialization and provides an apiCall method that automatically manages loading states.
Generics:
TApi: The type of your generated API client (e.g.,PetApi).TConfig: The configuration type for yourTApiclient (e.g.,Configurationfrom the generated client).
Key Members:
api: TApi | null(observable): The API client instance.apiIsSet: boolean(computed): True if the API client is initialized.setApi(api: TApi)(action): Sets the API client.initApi(config: TConfig)(action, must be implemented by subclasses): Initializes and sets the API client.apiCall(endpoint, args, options?)(flow): Calls an endpoint onthis.api. Uses keyed loading refcount unlessdisableLoadingis set (loadingKeydefaults to the endpoint viagetLoadingKey; override or passloadingKeyfor swiss-army scoping). Optionalapply(result)runs after success; withexclusiveKey, only the latest started call for that key applies (ignore-stale for replacement reads).getLoadingKey(endpoint, args): Default loading scope (String(endpoint)); override to include ids/args.isLoadingFor(key): Inherited keyed loading probe (seeLoadingStore).
Example:
import { ApiStore } from 'mobx-openapi-stores';
import { makeObservable, action } from 'mobx';
// Assuming you have a PetApi generated by OpenAPI
import { PetApi, Configuration as PetApiConfig, Pet } from './your-api-client';
class PetApiService extends ApiStore<PetApi, PetApiConfig> {
constructor() {
super('PetApiService'); // Pass a name for easier debugging
// initApi is bound, other actions might need makeObservable if added here
}
// Implementation of the abstract-like method
initApi(config: PetApiConfig) {
this.setApi(new PetApi(config));
}
async fetchPetById(petId: number): Promise<Pet | undefined> {
if (!this.apiIsSet) {
console.error('API not initialized');
return undefined;
}
// 'getPetById' is a method on your PetApi
// The second argument matches the parameters of PetApi.getPetById
return await this.apiCall('getPetById', { petId });
}
}
// const petStore = new PetApiService();
// petStore.initApi(new PetApiConfig({ basePath: "http://localhost:3000/api" }));
// petStore.fetchPetById(1).then(pet => console.log(pet));3. SingleStore<TApi, TSingle>
Extends ApiStore. Manages a single observable entity, referred to as current.
Purpose: Useful for scenarios like managing a selected item, user profile, or any standalone piece of data that might be fetched from an API.
Generics:
TApi: Inherited fromApiStore.TSingle: The type of the single entity being managed (must have anidproperty).
Key Members:
_current: TSingle | null(observable): The internal storage for the current entity.current: TSingle | null(computed): Provides access to_current.setCurrent(item: TSingle | null)(action): Sets the current entity.isCurrentSet: boolean(computed): True if_currentis not null.clearCurrent()(action): Sets_currentto null.editCurrent(updatedFields: Partial<TSingle>)(action): Merges updates into thecurrententity.
Example:
import { SingleStore } from 'mobx-openapi-stores';
import { makeObservable, action } from 'mobx';
import { UserApi, Configuration as UserApiConfig, User } from './your-api-client'; // Assuming User type
class UserProfileStore extends SingleStore<UserApi, User> {
constructor() {
super('UserProfileStore');
// makeObservable for custom actions
}
initApi(config: UserApiConfig) {
this.setApi(new UserApi(config));
}
async fetchUserProfile(userId: string) {
const user = await this.apiCall('getUserById', { userId }); // Assuming getUserById endpoint
if (user) {
this.setCurrent(user as User);
}
}
updateUserName(newName: string) {
if (this.current) {
this.editCurrent({ name: newName });
// Optionally, call API to persist change:
// this.apiCall('updateUser', { userId: this.current.id, userDto: { name: newName }});
}
}
}
// const profileStore = new UserProfileStore();
// profileStore.initApi(new UserApiConfig({ basePath: "/api" }));
// profileStore.fetchUserProfile('user123');4. CollectionStore<TApi, TSingle, TCollection>
Extends SingleStore. Manages a collection (array) of observable entities. The current item from SingleStore can be used to represent a selected item from this collection.
Purpose: Ideal for managing lists of items, such as products, posts, todos, etc.
Generics:
TApi: Inherited.TSingle: The type of individual items in the collection (must have anid).TCollection: The type of the collection itself, defaults toTSingle[].
Key Members:
collection: TCollection(computed): Accessor for the observable collection.setCollection(newCollection: TCollection)(action): Replaces the entire collection.addItem(newItem: TSingle, setCurrent?: boolean)(action): Adds an item.setItem(item: TSingle, setCurrent?: boolean)(action): Updates an existing item or adds it if not present.editItem(updatedItem: TSingle, setCurrent?: boolean)(action): Merges updates into an existing item by ID.removeItem(id: TSingle['id'])(action): Removes an item by ID.getById(id: TSingle['id']): TSingle | undefined: Retrieves an item by ID (checkscurrentfirst, thencollection).
Example:
import { CollectionStore } from 'mobx-openapi-stores';
import { makeObservable, action } from 'mobx';
import {
ProductApi,
Configuration as ProductApiConfig,
Product,
} from './your-api-client';
class ProductListStore extends CollectionStore<ProductApi, Product> {
constructor() {
super('ProductListStore');
// makeObservable for custom actions
}
initApi(config: ProductApiConfig) {
this.setApi(new ProductApi(config));
}
async fetchProducts() {
// Assuming 'listProducts' is an endpoint that returns Product[]
const products = await this.apiCall('listProducts', {});
if (products) {
this.setCollection(products as Product[]);
}
}
selectProduct(productId: string) {
const product = this.getById(productId);
if (product) {
this.setCurrent(product); // Sets the selected product as 'current'
}
}
}
// const productStore = new ProductListStore();
// productStore.initApi(new ProductApiConfig({ basePath: "/api" }));
// productStore.fetchProducts();5. CrudCollectionStore<TApi, TSingle>
Extends CollectionStore<TApi, TSingle>. This store provides a foundation for collections that require API-driven CRUD (Create, Read, Update, Delete) operations. Instead of direct CRUD methods, it offers protected helper methods (e.g., _fetchAll, _create) that handle the API interaction and state updates. Subclasses implement public-facing methods that call these protected helpers, often using MobX flow for asynchronous actions.
Purpose: Simplifies creating stores for collections that need API-based CRUD, by handling common logic for API calls and local state management (collection and current item updates).
Generics:
TApi: The type of your generated API client (e.g.,BudgetApi). Inherited fromCollectionStore.TSingle: The type of individual items in the collection (must have anid). Inherited fromCollectionStore.TCollection: The type of the collection, defaults toTSingle[]. Inherited fromCollectionStore.
Key Protected Helper Methods (to be called by subclass methods):
_fetchAll(endpoint: keyof TApi, params: any): Promise<TSingle[] | undefined>: Fetches all items using the specified APIendpointandparams. Updatesthis.collectionon success._fetch(endpoint: keyof TApi, params: any): Promise<TSingle | undefined>: Fetches a single item by ID using the specified APIendpointandparams. Setsthis.currentand updates the item inthis.collectionon success._create(endpoint: keyof TApi, params: any): Promise<TSingle | undefined>: Creates an item using the specified APIendpointandparams. Adds the new item tothis.collectionand sets it asthis.currenton success._update(endpoint: keyof TApi, params: any): Promise<TSingle | undefined>: Updates an item using the specified APIendpointandparams. Updates the item inthis.collectionandthis.current(if it's the same item) on success._delete(endpoint: keyof TApi, params: any): Promise<void | undefined>: Deletes an item using the specified APIendpointandparams. Removes the item fromthis.collectionand clearsthis.currentif it was the deleted item.
ApiResult<TApi, TEndpointName> Utility Type:
This package exports an ApiResult utility type: type ApiResult<TApi extends ApiType, TEndpointName extends keyof TApi> = ThenArg<ReturnType<TApi[TEndpointName]>>; (where ThenArg extracts the resolved type of a Promise).
It's used to strongly type the return values of your flow-wrapped API calls, representing the data returned by the specific API endpoint.
Example (BudgetStore):
import {
Configuration,
CreateBudgetDto,
Budget,
BudgetApi,
UpdateBudgetDto,
} from './your-api-client'; // Assuming these types from your generated API client
import {
CrudCollectionStore,
type ApiResult,
toFlowGeneratorFunction,
} from 'mobx-openapi-stores';
import { action, flow, makeObservable } from 'mobx';
// Define TApi for convenience if used multiple times
type Api = BudgetApi;
export class BudgetStore extends CrudCollectionStore<Api, Budget> {
constructor() {
super('BudgetStore'); // Name for debugging
makeObservable(this, {
// initApi is action.bound in ApiStore, no need to repeat unless overriding
// Public facing methods are MobX flows
fetchAll: flow,
fetchById: flow,
createBudget: flow,
updateBudget: flow,
deleteBudget: flow,
});
}
// Must be implemented by subclass of ApiStore
initApi(config: Configuration) {
this.setApi(new BudgetApi(config));
}
fetchAll = flow<ApiResult<Api, 'budgetControllerFindAllV1'>, []>(
toFlowGeneratorFunction(async () => {
// 'budgetControllerFindAllV1' is the method name on your BudgetApi
// Second argument is the parameters object for that endpoint
return await this._fetchAll('budgetControllerFindAllV1', {});
}),
);
fetchById = flow<ApiResult<Api, 'budgetControllerFindOneV1'>, [id: Budget['id']]>(
toFlowGeneratorFunction(async (id: Budget['id']) => {
return await this._fetch('budgetControllerFindOneV1', { id });
}),
);
createBudget = flow<
ApiResult<Api, 'budgetControllerCreateV1'>,
[budgetDto: CreateBudgetDto]
>(
toFlowGeneratorFunction(async (budgetDto: CreateBudgetDto) => {
return await this._create('budgetControllerCreateV1', {
createBudgetDto: budgetDto, // Parameter name must match the API client method
});
}),
);
updateBudget = flow<
ApiResult<Api, 'budgetControllerUpdateV1'>,
[id: Budget['id'], budgetDto: UpdateBudgetDto]
>(
toFlowGeneratorFunction(async (id: Budget['id'], budgetDto: UpdateBudgetDto) => {
return await this._update('budgetControllerUpdateV1', {
id, // Parameter name must match
updateBudgetDto: budgetDto, // Parameter name must match
});
}),
);
deleteBudget = flow<ApiResult<Api, 'budgetControllerRemoveV1'>, [id: Budget['id']]>(
toFlowGeneratorFunction(async (id: Budget['id']) => {
return await this._delete('budgetControllerRemoveV1', { id });
}),
);
}
// const budgetStore = new BudgetStore();
// budgetStore.initApi(new Configuration({ basePath: "/api" }));
// budgetStore.fetchAll();Note on flow Type Annotations:
Explicit type annotations for flow (e.g., flow<ReturnType, [Arg1Type, Arg2Type]>) along with toFlowGeneratorFunction are recommended for complex asynchronous actions. They enhance type safety and editor autocompletion, especially when TypeScript's type inference for generator functions might be incomplete. However, for simpler flows or if type inference works reliably, they might not always be strictly necessary.
6. ObjectStore<TApi, TKey, TTarget, TType, TObject>
Extends SingleStore (where TSingle from SingleStore corresponds to TTarget here). Manages a dictionary-like observable object where each key maps to either a single entity or a collection of entities.
Purpose: Useful for grouping items by a common key, e.g., tasks by project ID, comments by post ID. The current item from SingleStore refers to an individual TTarget item.
Generics:
TApi: Inherited.TKey: The type for the keys of the main observable object (e.g.,string,number).TTarget: The type of the individual items being stored (must have anid).TType:'single' | 'collection'- Specifies if entries are singleTTargetitems orTTarget[]. Defaults to'collection'.TObject: The overall shape of the observable object.
Key Members:
object: TObject(computed): Accessor for the observable object map.getEntryById(id: TKey): TObject[TKey] | undefined: Retrieves the entry (item or collection) for a key.setEntry(id: TKey, item: TObject[TKey]): Sets or updates an entry.removeEntry(id: TKey): Removes an entry.entryIsSet(id: TKey): boolean: Checks if an entry exists.- If
TTypeis'collection':addItem(entryId: TKey, item: TTarget): Addsitemto the collection atentryId.editItem(itemId: TTarget['id'], itemUpdateData: TTarget): Updatesitemwithin its collection.removeItem(itemId: TTarget['id'], entryId?: TKey): Removesitemfrom its collection.getItemById(itemId: TTarget['id']): TTarget | undefined: Finds an item across all collections.getEntryIdByItemId(itemId: TTarget['id']): TKey | undefined: Finds the entry key for an item.
Example (TType = 'collection'):
import { ObjectStore } from 'mobx-openapi-stores';
import { makeObservable, action } from 'mobx';
import {
CommentApi,
Configuration as CommentApiConfig,
Comment,
Post,
} from './your-api-client'; // Assuming types
// Store to manage comments grouped by post ID
class PostCommentsStore extends ObjectStore<
CommentApi,
Post['id'],
Comment,
'collection'
> {
constructor() {
super('PostCommentsStore');
// makeObservable for custom actions
}
initApi(config: CommentApiConfig) {
this.setApi(new CommentApi(config));
}
async fetchCommentsForPost(postId: Post['id']) {
// Assuming 'listCommentsByPostId' endpoint
const comments = await this.apiCall('listCommentsByPostId', { postId });
if (comments) {
this.setEntry(postId, comments as Comment[]);
}
}
async addComment(postId: Post['id'], commentData: { text: string }) {
// Assuming 'createComment' endpoint that takes postId and comment data
const newComment = await this.apiCall('createComment', {
postId,
commentDto: commentData,
});
if (newComment) {
if (!this.entryIsSet(postId)) {
this.setEntry(postId, []); // Initialize if first comment for this post
}
this.addItem(postId, newComment as Comment);
}
}
// current (from SingleStore) can be used to manage a currently selected/focused comment
selectComment(comment: Comment) {
this.setCurrent(comment);
}
}
// const commentsStore = new PostCommentsStore();
// commentsStore.initApi(new CommentApiConfig({ basePath: "/api" }));
// commentsStore.fetchCommentsForPost('post123');General Principles
makeObservable: Remember to callmakeObservablein your subclasses for any newaction,computed,flow, orobservableproperties you define. Base store properties are already made observable.initApi: Subclasses extendingApiStore(or stores derived from it) must implementinitApi(config: TConfig)to instantiate and set their specific API client.- Error Handling: The
apiCallmethod (and protected helpers inCrudCollectionStore) include basic error handling for API client initialization and manage loading states. You may want to add more specific error handling in your consuming code. - Naming: Pass a
namestring to the constructor of stores extendingApiStore(e.g.,super('MyStoreName')). This is used in error messages for easier debugging (e.g., "MyStoreName Api is not set").
TypeScript and Generics
These stores are heavily reliant on TypeScript generics. Understanding how to provide the correct types for TApi, TSingle, TConfig, etc., is crucial for leveraging their type-safety benefits. Refer to your OpenAPI-generated client for the specific types to use.
Upgrading
See MIGRATION.md for breaking changes in 2.0.
Contributing
Contributions are welcome! Please feel free to submit issues and pull requests.
License
This project is licensed under the MIT License - see the LICENSE.md.
