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.
- Less Boilerplate: Less code to write and maintain.
- Type-Safe: Leverages TypeScript and is designed to work seamlessly with types generated by OpenAPI tools.
- Reactive: Built with MobX for efficient and easy-to-manage state.
- Modular: Provides distinct store classes for different data management scenarios.
- Extensible: Base stores can be easily extended to add application-specific logic.
- Loading State Management: Integrated loading indicators for asynchronous operations.
- Simplified API Calls: Abstracted
apiCall
method for interacting with your API client. - Error Handling: Errors will automatically be processed and logged to the console.
npm install mobx-openapi-stores mobx
# or
yarn add mobx-openapi-stores mobx
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
: ExtendsCollectionStore
with helper methods for common API-driven CRUD operations.ObjectStore
: Manages a dictionary-like observable object, useful for grouping entities.
The most basic store, providing a simple isLoading
observable property and setIsLoading
action.
Purpose: To be extended by other stores that need to track loading states for asynchronous operations.
Key Members:
isLoading: boolean
(computed): True if an operation is in progress.setIsLoading(loading: boolean)
(action): Sets the loading state.
Example:
import { LoadingStore } from 'mobx-openapi-stores';
import { makeObservable, action } from 'mobx';
class MyCustomLoadingComponentStore extends LoadingStore {
constructor() {
super();
// makeObservable for custom actions if any
}
async performSomeTask() {
this.setIsLoading(true);
try {
// ... your async logic ...
await new Promise((resolve) => setTimeout(resolve, 1000));
} finally {
this.setIsLoading(false);
}
}
}
const store = new MyCustomLoadingComponentStore();
// autorun(() => console.log(store.isLoading));
// store.performSomeTask();
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 yourTApi
client (e.g.,Configuration
from 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: keyof TApi, args: any)
(flow): Makes an API call using the specified endpoint method onthis.api
.
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));
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 anid
property).
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_current
is not null.clearCurrent()
(action): Sets_current
to null.editCurrent(updatedFields: Partial<TSingle>)
(action): Merges updates into thecurrent
entity.
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');
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
(observable): The internal observable array.collection: TCollection
(computed): Accessor for_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 (checkscurrent
first, 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();
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 APIendpoint
andparams
. Updatesthis.collection
on success._fetch(endpoint: keyof TApi, params: any): Promise<TSingle | undefined>
: Fetches a single item by ID using the specified APIendpoint
andparams
. Setsthis.current
and updates the item inthis.collection
on success._create(endpoint: keyof TApi, params: any): Promise<TSingle | undefined>
: Creates an item using the specified APIendpoint
andparams
. Adds the new item tothis.collection
and sets it asthis.current
on success._update(endpoint: keyof TApi, params: any): Promise<TSingle | undefined>
: Updates an item using the specified APIendpoint
andparams
. Updates the item inthis.collection
andthis.current
(if it's the same item) on success._delete(endpoint: keyof TApi, params: any): Promise<void | undefined>
: Deletes an item using the specified APIendpoint
andparams
. Removes the item fromthis.collection
and clearsthis.current
if 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 } from 'mobx-openapi-stores';
import { action, flow, makeObservable } from 'mobx';
import { toFlowGeneratorFunction } from 'to-flow-generator-function'; // Utility to help with MobX flow typing
// 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.
Extends SingleStore
(where TSingle
from SingleStore
corresponds to TTarget
here). Manages a dictionary-like observable object (_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 singleTTarget
items orTTarget[]
. Defaults to'collection'
.TObject
: The overall shape of the observable object.
Key Members:
_object: TObject
(observable): The internal observable object map.object: TObject
(computed): Accessor for_object
.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
TType
is'collection'
:addItem(entryId: TKey, item: TTarget)
: Addsitem
to the collection atentryId
.editItem(itemId: TTarget['id'], itemUpdateData: TTarget)
: Updatesitem
within its collection.removeItem(itemId: TTarget['id'], entryId?: TKey)
: Removesitem
from 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');
makeObservable
: Remember to callmakeObservable
in your subclasses for any newaction
,computed
,flow
, orobservable
properties 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
apiCall
method (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
name
string 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").
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.
Contributions are welcome! Please feel free to submit issues and pull requests.
This project is licensed under the MIT License - see the LICENSE.md.