Migrate CRUD operations
This page covers how to migrate every DataStore CRUD operation and predicate/filter pattern to Apollo Client. DataStore conflates create and update into a single save() method and handles _version internally. With Apollo Client, you use distinct mutations for each operation and manage _version explicitly.
GraphQL operations used on this page (CREATE_POST, UPDATE_POST, DELETE_POST, GET_POST, LIST_POSTS, and the POST_DETAILS_FRAGMENT fragment) are defined on the Set up Apollo Client page. Import them as needed:
import { apolloClient } from './apolloClient';import { CREATE_POST, UPDATE_POST, DELETE_POST, GET_POST, LIST_POSTS,} from './graphql/operations';Create (save new record)
DataStore uses new Model() plus DataStore.save() to create a record. Apollo Client uses the CREATE_POST mutation.
DataStore (before):
const newPost = await DataStore.save( new Post({ title: 'My First Post', content: 'Hello world', status: 'PUBLISHED', rating: 5, }));Apollo Client (after) -- imperative:
const { data } = await apolloClient.mutate({ mutation: CREATE_POST, variables: { input: { title: 'My First Post', content: 'Hello world', status: 'PUBLISHED', rating: 5, }, },});const newPost = data.createPost;// newPost._version is 1 (set by AppSync automatically)Apollo Client (after) -- React hook:
import { useMutation } from '@apollo/client';
function CreatePostForm() { const [createPost, { loading, error }] = useMutation(CREATE_POST, { refetchQueries: [{ query: LIST_POSTS }], });
async function handleSubmit(title: string, content: string) { const { data } = await createPost({ variables: { input: { title, content, status: 'PUBLISHED', rating: 5 }, }, }); console.log('Created:', data.createPost.id); }
return ( <form onSubmit={(e) => { e.preventDefault(); handleSubmit('Title', 'Content'); }}> {error && <p>Error: {error.message}</p>} <button type="submit" disabled={loading}> {loading ? 'Creating...' : 'Create Post'} </button> </form> );}Key differences:
- No
_versionneeded for creates. AppSync sets_versionto 1 automatically on new records. refetchQueriesensures the list view updates after a create. DataStore handled this automatically through its local store; Apollo requires explicit cache management.
Update (modify existing record)
DataStore uses Model.copyOf() with an immer-based draft for immutable updates. Apollo Client uses the UPDATE_POST mutation with a plain object. Only changed fields need to be in the input.
DataStore (before):
const original = await DataStore.query(Post, '123');const updated = await DataStore.save( Post.copyOf(original, (draft) => { draft.title = 'Updated Title'; draft.rating = 4; }));Apollo Client (after) -- imperative:
// Step 1: Query the current record to get _versionconst { data: queryData } = await apolloClient.query({ query: GET_POST, variables: { id: '123' },});const post = queryData.getPost;
// Step 2: Mutate with _version from query resultconst { data } = await apolloClient.mutate({ mutation: UPDATE_POST, variables: { input: { id: '123', title: 'Updated Title', rating: 4, _version: post._version, // REQUIRED }, },});Apollo Client (after) -- React hook:
import { useQuery, useMutation } from '@apollo/client';
function EditPostForm({ postId }: { postId: string }) { const { data, loading: queryLoading } = useQuery(GET_POST, { variables: { id: postId }, }); const [updatePost, { loading: updating, error }] = useMutation(UPDATE_POST);
async function handleSave(title: string) { const post = data.getPost; await updatePost({ variables: { input: { id: post.id, title, _version: post._version, }, }, }); }
if (queryLoading) return <p>Loading...</p>;
return ( <div> {error && <p>Error: {error.message}</p>} <button onClick={() => handleSave('New Title')} disabled={updating}> {updating ? 'Saving...' : 'Save'} </button> </div> );}Key differences:
- No
copyOf()or immer pattern. Apollo uses plain objects -- pass only the fields you want to change. - Only changed fields +
id+_versionare needed. You do not need to send the entire record. - Two-step process: Query first (to get
_version), then mutate. DataStore handled this internally.
Delete (single record)
_version is REQUIRED for deletes. You must query the record first to get the current _version, even if you already have the ID.
DataStore (before):
const post = await DataStore.query(Post, '123');await DataStore.delete(post);Apollo Client (after) -- imperative:
// Step 1: Query to get current _versionconst { data: queryData } = await apolloClient.query({ query: GET_POST, variables: { id: '123' },});
// Step 2: Delete with _versionawait apolloClient.mutate({ mutation: DELETE_POST, variables: { input: { id: '123', _version: queryData.getPost._version, }, }, refetchQueries: [{ query: LIST_POSTS }],});Apollo Client (after) -- React hook:
import { useMutation } from '@apollo/client';
function DeletePostButton({ post }: { post: { id: string; _version: number } }) { const [deletePost, { loading }] = useMutation(DELETE_POST, { refetchQueries: [{ query: LIST_POSTS }], });
async function handleDelete() { await deletePost({ variables: { input: { id: post.id, _version: post._version }, }, }); }
return ( <button onClick={handleDelete} disabled={loading}> {loading ? 'Deleting...' : 'Delete'} </button> );}Key differences:
- No delete-by-ID shorthand. Apollo always needs the mutation input object with both
idand_version. - Delete is a soft delete when conflict resolution is enabled. The record's
_deletedfield is set totruein DynamoDB, but the record is not physically removed.
Query by ID
DataStore (before):
const post = await DataStore.query(Post, '123');if (post) { console.log(post.title);}Apollo Client (after):
const { data } = await apolloClient.query({ query: GET_POST, variables: { id: '123' },});const post = data.getPost;// Returns null instead of undefined when not foundif (post) { console.log(post.title);}List all records
DataStore (before):
const posts = await DataStore.query(Post);Apollo Client (after):
const { data } = await apolloClient.query({ query: LIST_POSTS });const posts = data.listPosts.items.filter((post) => !post._deleted);Batch delete (predicate-based)
DataStore supported deleting multiple records with a predicate. Apollo Client has no equivalent -- you must query the matching records first, then delete each one individually.
DataStore (before):
await DataStore.delete(Post, (p) => p.status.eq('DRAFT'));Apollo Client (after):
// Step 1: Query posts matching the filterconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { status: { eq: 'DRAFT' } } },});const drafts = data.listPosts.items.filter((post) => !post._deleted);
// Step 2: Delete each record individuallyconst results = await Promise.allSettled( drafts.map((post) => apolloClient.mutate({ mutation: DELETE_POST, variables: { input: { id: post.id, _version: post._version }, }, }) ));
// Step 3: Check for partial failuresconst failures = results.filter((r) => r.status === 'rejected');if (failures.length > 0) { console.error(`${failures.length} of ${drafts.length} deletes failed`);}
// Refresh the listawait apolloClient.refetchQueries({ include: [LIST_POSTS] });CRUD quick reference
| DataStore Method | Apollo Client Equivalent | Key Difference |
|---|---|---|
DataStore.save(new Model({...})) | apolloClient.mutate({ mutation: CREATE, variables: { input: {...} } }) | No _version needed for creates |
Model.copyOf(original, draft => {...}) + DataStore.save() | apolloClient.mutate({ mutation: UPDATE, variables: { input: { id, _version, ...changes } } }) | Must pass _version; plain object instead of immer draft |
DataStore.delete(instance) | apolloClient.mutate({ mutation: DELETE, variables: { input: { id, _version } } }) | Must query first to get _version |
DataStore.query(Model, id) | apolloClient.query({ query: GET, variables: { id } }) | Returns null instead of undefined when not found |
DataStore.query(Model) | apolloClient.query({ query: LIST }) | Must filter _deleted records from results |
DataStore.delete(Model, predicate) | Query with filter + delete each individually | No atomicity; use Promise.allSettled |
Filter operator mapping
DataStore uses callback-based predicates. Apollo Client and AppSync use JSON filter objects passed as query variables.
| Operator | DataStore Syntax | GraphQL Syntax | Notes |
|---|---|---|---|
eq | p.field.eq(value) | { field: { eq: value } } | Exact match |
ne | p.field.ne(value) | { field: { ne: value } } | Not equal |
gt | p.field.gt(value) | { field: { gt: value } } | Greater than |
ge | p.field.ge(value) | { field: { ge: value } } | Greater than or equal |
lt | p.field.lt(value) | { field: { lt: value } } | Less than |
le | p.field.le(value) | { field: { le: value } } | Less than or equal |
contains | p.field.contains(value) | { field: { contains: value } } | Substring match |
notContains | p.field.notContains(value) | { field: { notContains: value } } | Substring not present |
beginsWith | p.field.beginsWith(value) | { field: { beginsWith: value } } | String prefix match |
between | p.field.between(lo, hi) | { field: { between: [lo, hi] } } | Inclusive range |
in | p.field.in([v1, v2]) | NOT AVAILABLE | Use or + eq workaround |
notIn | p.field.notIn([v1, v2]) | NOT AVAILABLE | Use and + ne workaround |
Filter examples
eq -- Exact match:
// DataStoreconst published = await DataStore.query(Post, (p) => p.status.eq('PUBLISHED'));
// Apollo Clientconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { status: { eq: 'PUBLISHED' } } },});const published = data.listPosts.items.filter((p) => !p._deleted);contains -- Substring match:
// DataStoreconst reactPosts = await DataStore.query(Post, (p) => p.title.contains('React'));
// Apollo Clientconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { title: { contains: 'React' } } },});const reactPosts = data.listPosts.items.filter((p) => !p._deleted);between -- Inclusive range:
// DataStoreconst midRated = await DataStore.query(Post, (p) => p.rating.between(2, 4));
// Apollo Clientconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { rating: { between: [2, 4] } } },});const midRated = data.listPosts.items.filter((p) => !p._deleted);Combining conditions with and:
// DataStoreconst posts = await DataStore.query(Post, (p) => p.and((p) => [p.rating.gt(4), p.status.eq('PUBLISHED')]));
// Apollo Clientconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { and: [{ rating: { gt: 4 } }, { status: { eq: 'PUBLISHED' } }], }, },});Combining conditions with or:
const { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { or: [ { title: { contains: 'React' } }, { title: { contains: 'Apollo' } }, ], }, },});Negating with not:
const { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { not: { status: { eq: 'DRAFT' } } }, },});The in and notIn workaround
Replacing in with or + eq:
// DataStore: p.status.in(['PUBLISHED', 'DRAFT'])// Apollo: combine multiple eq conditions with orconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { or: [{ status: { eq: 'PUBLISHED' } }, { status: { eq: 'DRAFT' } }], }, },});Helper functions for in and notIn
function buildInFilter(field: string, values: string[]) { return { or: values.map((value) => ({ [field]: { eq: value } })), };}
function buildNotInFilter(field: string, values: string[]) { return { and: values.map((value) => ({ [field]: { ne: value } })), };}
// Usage:const { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: buildInFilter('status', ['PUBLISHED', 'DRAFT']) },});Pagination migration
DataStore uses page-based pagination (zero-indexed page number + limit). AppSync uses cursor-based pagination (nextToken + limit). This is not a rename -- it is a fundamental semantic change.
| Aspect | DataStore (Page-Based) | Apollo/AppSync (Cursor-Based) |
|---|---|---|
| Navigation | Random access -- jump to any page | Sequential only -- must traverse pages in order |
| Parameters | { page: 0, limit: 10 } | { limit: 10, nextToken: '...' } |
| First page | page: 0 | Omit nextToken (or pass null) |
| Next page | page: page + 1 | Use nextToken from previous response |
| End detection | items.length < limit | nextToken === null |
Apollo Client cursor-based pagination:
// Page 1 (first 10 items) -- no nextToken neededconst { data: page1Data } = await apolloClient.query({ query: LIST_POSTS, variables: { limit: 10 },});const page1Items = page1Data.listPosts.items.filter((p) => !p._deleted);const nextToken = page1Data.listPosts.nextToken;
// Page 2 -- use nextToken from previous responseif (nextToken) { const { data: page2Data } = await apolloClient.query({ query: LIST_POSTS, variables: { limit: 10, nextToken }, });}Load More pattern (React)
The most common pagination pattern with cursor-based pagination is "Load More" (infinite scroll):
import { useQuery } from '@apollo/client';
function PostList() { const { data, loading, error, fetchMore } = useQuery(LIST_POSTS, { variables: { limit: 10 }, });
if (loading && !data) return <p>Loading...</p>; if (error) return <p>Error: {error.message}</p>;
const posts = (data?.listPosts?.items ?? []).filter((p) => !p._deleted); const nextToken = data?.listPosts?.nextToken;
const handleLoadMore = () => { fetchMore({ variables: { limit: 10, nextToken }, updateQuery: (prev, { fetchMoreResult }) => { if (!fetchMoreResult) return prev; return { listPosts: { ...fetchMoreResult.listPosts, items: [ ...prev.listPosts.items, ...fetchMoreResult.listPosts.items, ], }, }; }, }); };
return ( <div> <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> <button onClick={handleLoadMore} disabled={!nextToken || loading}> {nextToken ? 'Load More' : 'No More Posts'} </button> </div> );}Sorting migration
DataStore supports SortDirection.ASCENDING and SortDirection.DESCENDING. AppSync's basic listModels query has no sortDirection argument by default.
Client-side sorting (recommended)
For most use cases, fetch results and sort them in JavaScript:
// DataStoreconst posts = await DataStore.query(Post, Predicates.ALL, { sort: (s) => s.createdAt(SortDirection.DESCENDING),});
// Apollo Clientconst { data } = await apolloClient.query({ query: LIST_POSTS });const posts = [...data.listPosts.items] .filter((p) => !p._deleted) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() );Server-side sorting (requires @index directive)
If your model has a Global Secondary Index (GSI) defined with the @index directive, AppSync generates a query with sortDirection support:
type Post @model { id: ID! title: String! status: String! @index(name: "byStatus", sortKeyFields: ["createdAt"]) createdAt: AWSDateTime!}This generates a postsByStatus query that accepts sortDirection:
const LIST_POSTS_BY_STATUS = gql` query PostsByStatus( $status: String! $sortDirection: ModelSortDirection $limit: Int $nextToken: String ) { postsByStatus( status: $status sortDirection: $sortDirection limit: $limit nextToken: $nextToken ) { items { ...PostDetails } nextToken } }`;
const { data } = await apolloClient.query({ query: LIST_POSTS_BY_STATUS, variables: { status: 'PUBLISHED', sortDirection: 'DESC', limit: 10 },});Server-side sorting requires backend schema changes and only works when querying by the index's partition key. For general-purpose sorting, use client-side sorting.
Common mistakes
Common CRUD migration mistakes
1. Forgetting _version in update or delete mutations
The most frequent migration error. DataStore handled _version internally. With Apollo, you must include it yourself.
2. Using CREATE mutation for updates
DataStore's save() handled both creates and updates. With Apollo, you must call the correct mutation.
3. Not filtering _deleted records from list results
DataStore automatically hid soft-deleted records. Apollo returns all records, including deleted ones. Always use .filter(item => !item._deleted) on list query results.
4. Not using refetchQueries after mutations
DataStore's local store automatically updated queries after mutations. Apollo's cache may not update list queries automatically. Add refetchQueries: [{ query: LIST_POSTS }] to mutations that affect list views.
5. Using stale _version values
If you cache a record's _version and another user or process updates the record, your mutation will fail. Re-query with fetchPolicy: 'network-only' before mutating when freshness is critical.