ConrabOpto / mst-query

Query library for mobx-state-tree
MIT License
110 stars 7 forks source link

Plans for v2 #30

Closed k-ode closed 1 year ago

k-ode commented 2 years ago

For the next version of mst-query, we're planning some breaking changes to the api. After using mst-query in production and learning things about it for more than a year now, there are things that we'd like to improve.

The following list is some of the changes we're actively considering.

Using queries and mutations as props on models

This is part of a goal to make mst-query more usable for classical mobx-state-tree applications, and in the long term also be able to support other rendering libraries other than React.

const MessagesQuery = createQuery('MessagesQuery', {
    data: types.array(MstQueryRef(MessageModel))
}).actions((self) => ({
    run: flow(function* () {
        const next = yield* self.query(getMessages);
        next();
    }),
}));

const MessageStore = types
  .model('Item', {
    messagesQuery: types.optional(MessagesQuery, {}),
    updateMessageMutation: types.optional(updateMessageMutation, {}),
  })
  .views(self => ({
    get messages() {
      return self.itemQuery.data;
    }
  })
  .actions((self) => ({
    afterCreate() {
      self.messagesQuery.setOptions({ staleTime: 100 });
      self.messagesQuery.run();
    },
  }));

const messageStore = MessageStore.create();

// Queries will trigger garbage collection when their owner object is destroyed
destroy(messageStore);

Since this is not a breaking change, this is already possible today in 1.1.0-alpha releases.

Change RequestModel

In hindsight, the current iteration of the request model seems like a mistake. While convenient in some cases, it's also very easy to end up with stale request data, and keeping component props and request state in sync tends to be very error prone.

Instead we'd like to encourage a pattern of sending data directly to run actions.

We do find validating request arguments useful, and the additional type safety it brings when using queryClient.find(q => q.request === ...). Therefore we're considering keeping it around as an optional way to get more type safety in your queries. In this version the request model will get updated every time you pass new data to self.query or self.mutate.

Remove env

This api is not important enough that it needs to be a first class api in mst-query. We don't do anything special with it and it's easy to add it back if you still want it.

Change how request works in react hooks

Most developers intution seems to be that whenever arguments in request changes, the query will re-run with the new arguments. This is how it works in react-query and similar libraries, but not in mst-query. The plan is to align the api and behaviour of mst-query to that of other query libraries.

The new request argument will automatically pass its contents to run of the query. Request will be removed fromuseMutation , as you’ll pass data to run directly.

const ChatQuery = createQuery('ChatQuery', {
    data: MstQueryRef(ChatModel)
}).actions((self) => ({
    run: flow(function* (chatId: number) {
        const next = yield* self.query(self.env.api.getChat, chatId);
        next();
    }),
}));

const { data } = useQuery(ChatQuery, {
  request: chatId // will re-run whenever id changes
});
const SendMessageMutation = createQuery('SendMessageMutation', {
    data: MstQueryRef(ChatModel)
}).actions((self) => ({
    run: flow(function* (message: string) {
        const next = yield* self.query(sendMessage, chatId);
        next();
    }),
}));

const [message] = useState('');
const [sendMessage] = useMutation(SendMessageMutation);
sendMessage(message);

Deprecate useLazyQuery in favor of an enabled prop

An enabled prop is a flexible solution to support many different patterns. A query will not run if enabled is false, and will defer running until it is true.

const {} = useQuery(ChatQuery, {
  enabled: shouldFetchQuery, // will only run if shouldFetchQuery is true
});

// Lazy query
const { query } = useQuery(ChatQuery, {
  enabled: false
});
const onChatChanged = () => {
  query.refetch();
};

// Dependent queries
const { data: user } = useQuery(UsernameQuery, { request: { username: 'KO' } });

const {} = useQuery(UserInvoicesQuery, {
  request: { id: user?.id },
  enabled: !!user?.id // this query will not run until user.id exists
});

Other things we’re looking at

React 18 & Suspense

We definitly want mst-query to work with React 18 and Suspense. It’s under investigation, but at this time we have no reason to believe that this will lead to any further breaking changes.

Invalidating & Prefetching

We want to add first class apis for invalidating and prefetching queries. The api will problaby be similar to react-query.

k-ode commented 2 years ago

Draft pr here: https://github.com/ConrabOpto/mst-query/pull/33

k-ode commented 2 years ago

Now available on npm as 2.0.0-alpha.1.

Remaining work:

The two last items might happen in a minor release following v2. But I do want to deprecate useLazyQuery and replace with an enabled-prop bere doing a proper release.

k-ode commented 1 year ago

React 18 does seem to require a re-think of the api somewhat. A mutable object cannot reliably be stored in a useState hook, it needs to be external to react.

My current thinking is that we double down on the "queries as properties" api, which will solve the React 18 issues AND make mst-query work better with non react setups. The drawback is that you will have to manually garbage collect queries (normalized models will still be gc'ed automatically).

Here's how I think this will look:


const DocumentQuery = createQuery('DocumentQuery', {
    data: DocumentModel,
    request: types.model({ id: types.string }),
});

const AddCommentMutation = createMutation('AddCommentMutation', {
    data: CommentModel,
    request: types.model({ text: types.string }),
});

const CommentListQuery = createMutation('CommentListQuery', {
    data: CommentList,
    request: types.model({ id: types.string }),
    pagination: types.model({ offset: 0 }),
});

// Store api
const DocumentStore = types
    .model({
        documentQuery: DocumentQuery,
        addCommentMutation: AddCommentMutation,
        commentListQuery: CommentListQuery,
    })
    .actions((self) => ({
        getDocument: flow(function* ({ id }) {
            const next = self.documentQuery.query({ request: { id } });
            next();
        }),
        getComments: flow(function* ({ id }) {
            const next = self.commentListQuery.query({ request: { id } });
            next();
        }),
        getMoreComments: flow(function* (offset) {
            const next = self.commentListQuery.queryMore({
                request: { id },
                pagination: { offset },
            });
            const { data } = next();
            self.commentListQuery.data.items.push(...data.items);
        }),
        addComment: flow(function* (text) {
            const next = self.addCommentMutation.mutate({ request: { text } });
            const { data } = next();

            // add directly to local query
            self.commentListQuery.data.item.push(data);

            // or use query store to add to all matching queries
            const { queryStore } = getQueryClient(self);
            const commentList = queryStore.findAll(CommentListQuery);
            commentList.forEach(list => list.data?.items.push(data));
        }),
    }));

// Hook api
const DocumentStore = types.model({
    documentQuery: DocumentQuery,
    documentMutation: DocumentMutation,
    commentListQuery: CommentListQuery,
});
const store = DocumentStore.create();

const CommentList = observer(({ id }) => {
    const { data } = useQuery(store.commentListQuery, store.getComments, {
        request: { id },
        onQueryMore: () => store.getMoreComments(offset)
    });
    const [addComment] = useMutation(store.documentMutation, store.addComment);

    const handleAddComment = () => {
        addComment({ text: 'new text' });
    };

    if (!data) {
        return <div>Loading comments...</div>;
    }

    return (
        <div>
            <div>
                {data.items.map((comment) => (
                    <Comment comment={comment} />
                ))}
            </div>
            <button onClick={handleAddComment}>Add comment</button>
        </div>
    );
});

const Document = observer(({ id }) => {
    const store = useStore();
    const { data } = useQuery(store.documentQuery, store.getDocument, { 
        request: { id } 
    });

    if (!data) {
        return <div>Loading document...</div>;
    }

    return <CommentList id={id} />;
});
k-ode commented 1 year ago

Beta version now available as 2.0.0-beta.1. This release is more or less complete, there's some minor adjustments and docs work left.

A demo working with React 18 and Strict Mode: https://codesandbox.io/s/mst-query-table-filters-wdforg

k-ode commented 1 year ago

v2 is now released