Query library for mobx-state-tree
Technically speaking, MobX-State-Tree (also known as MST) is a state container system built on MobX, a functional reactive state library.
Features
- Automatic Normalization
- Automatic Garbage Collection
- Use with any backend (REST, GraphQL, whatever!)
- Infinite Scroll + Pagination Queries
- Mutations + Change Tracking
- Request Argument Type Validation
- Abort Requests
- Optimistic Mutations
Examples
Basic Usage
First, create some models and a query...
import { flow, types } from 'mobx-state-tree';
import { createQuery, MstQueryRef, RequestModel } from 'mst-query';
const UserModel = types.model('UserModel', {
id: types.identifier,
name: types.string,
age: types.number,
});
const MessageModel = types.model('MessageModel', {
id: types.identifier,
message: types.string,
created: types.Date,
createdBy: MstQueryRef(UserModel),
});
const getItem = ({ request }) => {
const { id } = request;
return fetch('...').then((res) => res.json());
};
const MessageQuery = createQuery('MessageQuery', {
data: MstQueryRef(MessageModel),
request: RequestModel.props({ id: types.string }),
}).actions((self) => ({
run: flow(function* () {
const next = yield* self.query(getItem);
const { data, result, error } = next<typeof MessageQuery>();
}),
}));
...then use the query in a React component!
import { useQuery } from 'mst-query';
import { observer } from 'mobx-react';
import { MessageQuery } from './MessageQuery';
const MesssageView = observer((props) => {
const { id } = props;
const { data, error, isLoading } = useQuery(MessageQuery, {
request: { id },
});
if (error) {
return <div>An error occured...</div>;
}
if (isLoading) {
return <div>Loading...</div>;
}
return <div>{data.message}</div>;
});
Current state
The current version of this library is a beta release, and the api might still change between releases. However, we don't expect any major rewrites and future upgrades should be fairly straightforward.
Documentation
Installation
npm install --save mst-query mobx-state-tree
Configuration
import { configure } from 'mst-query';
configureMstQuery({
env: { ... },
staleTime: number,
cacheTime: number
});
Concepts
A key concept in mobx-state-tree is a single, centralized state container that holds the entire state of our app. This keeps our business logic in one place, allowing our components to mostly focus on rendering.
But there are a couple of trade offs to consider.
-
The RootStore needs knowledge of all our models
In a typical mobx-state-tree app, there's a root store that holds references to every model, split into multiple domain stores. If you want to support code splitting your models, you need to break up your root store into multiple instances. This is bad for the user that only utilizes a small portion of our app. Also if the model bundle is large, it slows down the startup time for all users.
In contrast, mst-query only needs knowledge of the models relevant for the current query.
-
Unused data lives in the store forever
Most applications problaby don't manage enough data for memory usage to be much of an issue. But consider an app with thousands of complex models and high data throughput, that is also kept open for long periods of time. Such an app will become more sluggish over time as it accumulates memory.
In mst-query, unused data is automatically garbage collected.
-
Normalizing data from the server is our responsibility
Normalizing remote data and putting it in the correct store can be tedious and error prone. Especially if you have a complex backend schema with deep connections between models.
A key feature of mst-query is automatic data normalization based on identifiers in our mobx-state-tree models.
Models
In general, models can be created as usual. The main difference is how we handle references.
MstQueryRef
A custom reference that replaces types.reference
.
import { types } from 'mobx-state-tree';
const UserModel = types.model({
id: types.identifier, // a normal identifier
name: types.string.
age: types.number
});
const MessageModel = types.model({
message: types.string,
createdBy: MstQueryRef(UserModel)
});
Since data is garbage collected in mst-query, MstQueryRef
doesn't throw if it cannot find a suitable model in the internal cache. Instead, it simply returns the id as a string, allowing us to fetch data for this model again.
Queries
createQuery
A query is just a mobx-state-tree model, but with special properties, called a QueryModel
. Here's an example of a query that fetches a list of messages.
import { types } from 'mobx-state-tree';
import { createQuery, RequestModel } from 'mst-query';
import { MessageModel } from './models';
import { getItems } from './api';
const MessageListQuery = createQuery('MessageListQuery', {
data: types.model({ items: types.array(MstQueryRef(MessageModel)) }),
request: RequestModel.props({ filter: types.optional(types.string, '') }),
}).actions((self) => ({
run: flow(function* () {
const next = yield* self.query(getItems);
const { result, error, data } = next<typeof MessageListQuery>();
}),
}));
The first argument to createQuery
is the name of this query. The second is an option object that controls how this query recevies (data) and transmits (request) data. In env
, you can put anything this query needs that does not fit in data or request.
There's also a special action, run
. This action should always be a flow generator, and will automatically be called when a query is put into a useQuery
hook.
useQuery
import { useQuery } from 'mst-query';
import { observer } from 'mobx-react';
import { MessageQuery } from './MessageQuery';
const MesssageView = observer((props) => {
const { id, snapshot, result } = props;
const {
run,
data,
error,
isLoading,
isFetched,
isRefetching,
isFetchingMore,
query,
} = useQuery(MessageQuery, {
data: snapshot,
request: { id },
env,
onFetched(data, self) {},
afterCreate(self) {},
onRequestSnapshot(snapshot) {},
key: id,
staleTime: 0,
cacheTime: 300,
});
if (error) {
return <div>An error occured...</div>;
}
if (isLoading) {
return <div>Loading...</div>;
}
return <div>{data.message}</div>;
});
The key
argument is optional and works like putting a key prop on a React component. If this variable changes, the entire query will be re-created and run again.
Note that data
, request
and env
are only set on creation.
The options staleTime
and cacheTime
controls how long we should use the cached value of this query.
useLazyQuery
import { useLazyQuery } from 'mst-query';
import { observer } from 'mobx-react';
import { MessageQuery } from './MessageQuery';
const MesssageView = observer((props) => {
const { id, cachedData } = props;
const { data, error, isLoading, query } = useLazyQuery(MessageQuery, {
data: cachedData,
request: { id },
});
useEffect(() => {
query.run();
}, []);
if (error) {
return <div>An error occured...</div>;
}
if (isLoading) {
return <div>Loading...</div>;
}
return <div>{data.message}</div>;
});
A lazy version of useQuery
. This hook is useful if you have cached data and manually want to decide when to run the query.
Paginated and infinite lists
queryMore
import { types } from 'mobx-state-tree';
import { createQuery, RequestModel } from 'mst-query';
import { MessageModel } from './models';
import { getItems } from './api';
const MessageListQuery = createQuery('MessageListQuery', {
data: types.model({ items: types.array(MstQueryRef(MessageModel)) }),
request: RequestModel.props({
filter: types.optional(types.string, ''),
}),
pagination: types.model({ offset: types.number, limit: types.number }),
}).actions((self) => ({
run: flow(function* () {
const next = yield* self.query(getItems);
next();
}),
fetchMore(offset: number) {
self.pagination.offset = offset;
const next = yield * self.queryMore(getItems);
const { data } = next<typeof MessageListQuery>();
self.data.items.push(data.items);
},
}));
The difference between query
and queryMore
is that the latter does not automatically merge it's result to the underlying query. This allows you to easily control how the data is appended to your list. It also means mst-query supports many different forms of pagination (offset-based, cursor-based, page-number-based) out of the box.
import { useQuery } from 'mst-query';
import { observer } from 'mobx-react';
import { MessageListQuery } from './MessageListQuery';
const MesssageListView = observer((props) => {
const { data, isFetchingMore, query } = useQuery(MessageListQuery, {
pagination: { offset: 0, limit: 20 },
});
if (isFetchingMore) {
return <div>Is fetching more results...</div>;
}
return (
<div>
{data.items.map((item) => (
<Message />
))}
<button onClick={() => query.fetchMore(data.items.length)}>Get more messages</button>
</div>
);
});
Mutations
createMutation
import { types } from 'mobx-state-tree';
import { createMutation, RequestModel } from 'mst-query';
import { MessageModel } from './models';
import { addMessage } from './api';
const AddMessageMutation = createMutation('AddMessage', {
data: MstQueryRef(MessageModel),
request: RequestModel.props({ message: types.string, userId: types.number }),
})
.views((self) => ({
get canRun() {
return !self.isLoading && self.request.message.length > 0;
},
}))
.actions((self) => ({
run: flow(function* () {
const next = yield* self.mutate(addMessage);
const { data } = next<typeof AddMessageMutation>();
// add new message to query
const messageList = queryCache.find(MessageListQuery);
messageList?.addMessage(data);
self.request.reset(); // restore request model to initial state
}),
setMessage(message: string) {
self.request.message = message;
},
}));
useMutation
import { useMutation } from 'mst-query';
import { observer } from 'mobx-react';
import { AddMessageMutation } from './AddMessageMutation';
const AddMessage = observer((props) => {
const [addMessage, { mutation } = useMutation(AddMessageMutation, {
request: { message: '', userId: 1 },
});
return (
<div>
<textarea
value={mutation.request.message}
onChange={ev => mutation.setMessage(ev.target.value)} />
<button
type="button"
disabled={!mutation.canRun}
onClick={() => addMessage()}>Send</button>
</div>
);
});
Optimistic updates
import { types } from 'mobx-state-tree';
import { createMutation, createOptimisticData, RequestModel } from 'mst-query';
import { MessageListQueries } from './queries';
import { MessageModel } from './models';
import { addMessage } from './api';
const AddMessageMutation = createMutation('AddMessage', {
data: MstQueryRef(MessageModel),
request: RequestModel.props({ message: types.string, userId: types.number }),
}).actions((self) => ({
run: flow(function* () {
const query = queryCache.find(MessageListQuery);
const optimistic = createOptimisticData(ItemModel, itemData);
query?.addItem(optimistic);
const next = yield* self.mutate(addMessage);
const { data } = next<typeof AddMessageMutation>();
query?.removeItem(optimistic);
query?.addItem(data);
}),
setMessage(message: string) {
self.request.message = message;
},
}));
Optimistically updating the UI can be quite involved. You have to create a unique id, and make sure client data is replaced with server data as soon as possible - without the user noticing any changes.
In mst-query, we try to make this easier by providing a createOptimisticData
helper. This function creates an temporary, unique id and merges the data so that it is ready to be added to the ui.
A difference from other query libraries is that you get imperative control of how this update happens. In the example above we simply replace the optimistic item with the server response, but you could of course do it differently if you wanted to.
However, note that mobx-state-tree does not currently support mutable identifers (see this issue). This is important becasue it means that trying to reuse the same instance won't work.
Change tracking
hasChanges
& commit
import { types } from 'mobx-state-tree';
import { createMutation, createOptimisticData, RequestModel } from 'mst-query';
import { MessageListQueries } from './queries';
import { MessageModel } from './models';
import { updateMessage } from './api';
const UpdateMessageMutation = createMutation('UpdateMessage', {
data: MstQueryRef(MessageModel),
request: RequestModel.props({ messageId: types.string, message: types.string }),
}).actions((self) => ({
run: flow(function* () {
const next = yield* self.mutate(updateMessage);
self.request.commit();
}),
setMessage(message: string) {
self.request.message = message; // now `self.request.hasChanges` is true!
},
restore() {
self.request.reset(); // reset request to initial state
},
}));
import { useMutation } from 'mst-query';
import { observer } from 'mobx-react';
import { AddMessageMutation } from './AddMessageMutation';
const EditMessage = observer((props) => {
const { message } = props;
const [addMessage, { mutation } = useMutation(UpdateMessageMutation, {
request: { messageId: message.id, message: message.message },
});
return (
<div>
<textarea
value={mutation.request.message}
onChange={ev => mutation.setMessage(ev.target.value)} />
<button
type="button"
disabled={mutation.request.hasChanges} // hasChanged is false initially
onClick={() => addMessage()}>Save</button>
<button type="button" onClick={() => mutation.request.reset()}>Reset</button>
</div>
);
});
Subscriptions
import { types } from 'mobx-state-tree';
import { createSubscription, MstQueryRef } from 'mst-query';
import { MessageModel } from './models';
type Subscriber = {
next: (value: any) => {};
error: (error: any) => {};
};
export class RealtimeService {
connection = null;
subscriptions = [];
constructor() {
// a made up example of a connection that pushes data over websockets
this.connection = new WebsocketConnection();
this.connection.on('new-message', (data) => {
this.subscriptions.forEach((sub) => sub.next(data));
});
}
subscribe(subscriber: Subscriber) {
this.subscriptions.push(subscriber);
// automatically called when the current useSubscription hook is no longer in use
return () => {
this.subscriptions = this.subscriptions.filter((sub) => sub !== subscriber);
};
}
}
const realtimeService = new RealtimeService();
export const NewMessageSubscription = createSubscription('NewMessageSubscription', {
data: MstQueryRef(MessageModel),
}).actions((self) => ({
run() {
self.subscribe((subscriber: Subscriber) => realtimeService.subscribe(subscriber));
},
shouldUpdate(result) {
// this can be used to skip an update
return true;
},
onUpdate() {
const message = self.data;
if (!message) {
return;
}
const messageListQuery = queryCache.find(MessageListQuery);
messageListQuery?.addMessage(message);
},
}));
export const MessageList: React.FC = observer(props => {
useSubscription(NewMessageSubscription, {
onUpdate(data: any) {
console.log('new message');
}
});
return <div></div>;
}));
Cache
Queries are cached by model type, request arguments and query function passed to run. Stale time is how much time should pass before a cached value needs to be refetched from the server. Cache time controls how long a query will remain in the cache after it is no longer in use.
queryCache
const query = queryCache.find(MessageQuery);
const queryWithId = queryCache.find(MessageQuery, (q) => q.request.id === 'message-id');
const allMessageMutations = queryCache.findAll(UpdateMessageMutation, (q) => true);