about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/api/index.ts11
-rw-r--r--src/state/modals/index.tsx18
-rw-r--r--src/state/models/content/list-membership.ts130
-rw-r--r--src/state/models/content/list.ts508
-rw-r--r--src/state/models/content/profile.ts4
-rw-r--r--src/state/models/lists/lists-list.ts244
-rw-r--r--src/state/models/ui/profile.ts1
-rw-r--r--src/state/queries/actor-autocomplete.ts66
-rw-r--r--src/state/queries/list-members.ts31
-rw-r--r--src/state/queries/list-memberships.ts190
-rw-r--r--src/state/queries/list.ts285
-rw-r--r--src/state/queries/my-follows.ts43
-rw-r--r--src/state/queries/my-lists.ts89
-rw-r--r--src/state/queries/profile-lists.ts31
-rw-r--r--src/view/com/lists/ListCard.tsx6
-rw-r--r--src/view/com/lists/ListMembers.tsx (renamed from src/view/com/lists/ListItems.tsx)94
-rw-r--r--src/view/com/lists/ListsList.tsx70
-rw-r--r--src/view/com/modals/CreateOrEditList.tsx32
-rw-r--r--src/view/com/modals/ListAddRemoveUsers.tsx (renamed from src/view/com/modals/ListAddUser.tsx)134
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx4
-rw-r--r--src/view/com/modals/UserAddRemoveLists.tsx309
-rw-r--r--src/view/screens/Lists.tsx18
-rw-r--r--src/view/screens/ModerationModlists.tsx18
-rw-r--r--src/view/screens/ProfileList.tsx494
25 files changed, 1296 insertions, 1538 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 9d48a78c0..a98834888 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -4,6 +4,7 @@ import {
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   AppBskyRichtextFacet,
+  BskyAgent,
   ComAtprotoLabelDefs,
   ComAtprotoRepoUploadBlob,
   RichText,
@@ -53,18 +54,18 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
 }
 
 export async function uploadBlob(
-  store: RootStoreModel,
+  agent: BskyAgent,
   blob: string,
   encoding: string,
 ): Promise<ComAtprotoRepoUploadBlob.Response> {
   if (isWeb) {
     // `blob` should be a data uri
-    return store.agent.uploadBlob(convertDataURIToUint8Array(blob), {
+    return agent.uploadBlob(convertDataURIToUint8Array(blob), {
       encoding,
     })
   } else {
     // `blob` should be a path to a file in the local FS
-    return store.agent.uploadBlob(
+    return agent.uploadBlob(
       blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
       {encoding},
     )
@@ -135,7 +136,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
       await image.compress()
       const path = image.compressed?.path ?? image.path
       const {width, height} = image.compressed || image
-      const res = await uploadBlob(store, path, 'image/jpeg')
+      const res = await uploadBlob(store.agent, path, 'image/jpeg')
       images.push({
         image: res.data.blob,
         alt: image.altText ?? '',
@@ -185,7 +186,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
         }
         if (encoding) {
           const thumbUploadRes = await uploadBlob(
-            store,
+            store.agent,
             opts.extLink.localThumb.path,
             encoding,
           )
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 287bbe593..6c63d9fc1 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -1,11 +1,10 @@
 import React from 'react'
-import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
 import {StyleProp, ViewStyle, DeviceEventEmitter} from 'react-native'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 
 import {ProfileModel} from '#/state/models/content/profile'
 import {ImageModel} from '#/state/models/media/image'
-import {ListModel} from '#/state/models/content/list'
 import {GalleryModel} from '#/state/models/media/gallery'
 
 export interface ConfirmModal {
@@ -55,7 +54,7 @@ export type ReportModal = {
 export interface CreateOrEditListModal {
   name: 'create-or-edit-list'
   purpose?: string
-  list?: ListModel
+  list?: AppBskyGraphDefs.ListView
   onSave?: (uri: string) => void
 }
 
@@ -67,10 +66,13 @@ export interface UserAddRemoveListsModal {
   onRemove?: (listUri: string) => void
 }
 
-export interface ListAddUserModal {
-  name: 'list-add-user'
-  list: ListModel
-  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
+export interface ListAddRemoveUsersModal {
+  name: 'list-add-remove-users'
+  list: AppBskyGraphDefs.ListView
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: AppBskyActorDefs.ProfileViewBasic,
+  ) => void
 }
 
 export interface EditImageModal {
@@ -184,7 +186,7 @@ export type Modal =
   // Lists
   | CreateOrEditListModal
   | UserAddRemoveListsModal
-  | ListAddUserModal
+  | ListAddRemoveUsersModal
 
   // Posts
   | AltTextImageModal
diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts
deleted file mode 100644
index 135d34dd5..000000000
--- a/src/state/models/content/list-membership.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {AtUri, AppBskyGraphListitem} from '@atproto/api'
-import {runInAction} from 'mobx'
-import {RootStoreModel} from '../root-store'
-
-const PAGE_SIZE = 100
-interface Membership {
-  uri: string
-  value: AppBskyGraphListitem.Record
-}
-
-interface ListitemRecord {
-  uri: string
-  value: AppBskyGraphListitem.Record
-}
-
-interface ListitemListResponse {
-  cursor?: string
-  records: ListitemRecord[]
-}
-
-export class ListMembershipModel {
-  // data
-  memberships: Membership[] = []
-
-  constructor(public rootStore: RootStoreModel, public subject: string) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  // public api
-  // =
-
-  async fetch() {
-    // NOTE
-    // this approach to determining list membership is too inefficient to work at any scale
-    // it needs to be replaced with server side list membership queries
-    // -prf
-    let cursor
-    let records: ListitemRecord[] = []
-    for (let i = 0; i < 100; i++) {
-      const res: ListitemListResponse =
-        await this.rootStore.agent.app.bsky.graph.listitem.list({
-          repo: this.rootStore.me.did,
-          cursor,
-          limit: PAGE_SIZE,
-        })
-      records = records.concat(
-        res.records.filter(record => record.value.subject === this.subject),
-      )
-      cursor = res.cursor
-      if (!cursor) {
-        break
-      }
-    }
-    runInAction(() => {
-      this.memberships = records
-    })
-  }
-
-  getMembership(listUri: string) {
-    return this.memberships.find(m => m.value.list === listUri)
-  }
-
-  isMember(listUri: string) {
-    return !!this.getMembership(listUri)
-  }
-
-  async add(listUri: string) {
-    if (this.isMember(listUri)) {
-      return
-    }
-    const res = await this.rootStore.agent.app.bsky.graph.listitem.create(
-      {
-        repo: this.rootStore.me.did,
-      },
-      {
-        subject: this.subject,
-        list: listUri,
-        createdAt: new Date().toISOString(),
-      },
-    )
-    const {rkey} = new AtUri(res.uri)
-    const record = await this.rootStore.agent.app.bsky.graph.listitem.get({
-      repo: this.rootStore.me.did,
-      rkey,
-    })
-    runInAction(() => {
-      this.memberships = this.memberships.concat([record])
-    })
-  }
-
-  async remove(listUri: string) {
-    const membership = this.getMembership(listUri)
-    if (!membership) {
-      return
-    }
-    const {rkey} = new AtUri(membership.uri)
-    await this.rootStore.agent.app.bsky.graph.listitem.delete({
-      repo: this.rootStore.me.did,
-      rkey,
-    })
-    runInAction(() => {
-      this.memberships = this.memberships.filter(m => m.value.list !== listUri)
-    })
-  }
-
-  async updateTo(
-    uris: string[],
-  ): Promise<{added: string[]; removed: string[]}> {
-    const added = []
-    const removed = []
-    for (const uri of uris) {
-      await this.add(uri)
-      added.push(uri)
-    }
-    for (const membership of this.memberships) {
-      if (!uris.includes(membership.value.list)) {
-        await this.remove(membership.value.list)
-        removed.push(membership.value.list)
-      }
-    }
-    return {added, removed}
-  }
-}
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
deleted file mode 100644
index fc09eeb9f..000000000
--- a/src/state/models/content/list.ts
+++ /dev/null
@@ -1,508 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AtUri,
-  AppBskyActorDefs,
-  AppBskyGraphGetList as GetList,
-  AppBskyGraphDefs as GraphDefs,
-  AppBskyGraphList,
-  AppBskyGraphListitem,
-  RichText,
-} from '@atproto/api'
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import chunk from 'lodash.chunk'
-import {RootStoreModel} from '../root-store'
-import * as apilib from 'lib/api/index'
-import {cleanError} from 'lib/strings/errors'
-import {bundleAsync} from 'lib/async/bundle'
-import {track} from 'lib/analytics/analytics'
-import {until} from 'lib/async/until'
-import {logger} from '#/logger'
-
-const PAGE_SIZE = 30
-
-interface ListitemRecord {
-  uri: string
-  value: AppBskyGraphListitem.Record
-}
-
-interface ListitemListResponse {
-  cursor?: string
-  records: ListitemRecord[]
-}
-
-export class ListModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  loadMoreError = ''
-  hasMore = true
-  loadMoreCursor?: string
-
-  // data
-  data: GraphDefs.ListView | null = null
-  items: GraphDefs.ListItemView[] = []
-  descriptionRT: RichText | null = null
-
-  static async createList(
-    rootStore: RootStoreModel,
-    {
-      purpose,
-      name,
-      description,
-      avatar,
-    }: {
-      purpose: string
-      name: string
-      description: string
-      avatar: RNImage | null | undefined
-    },
-  ) {
-    if (
-      purpose !== 'app.bsky.graph.defs#curatelist' &&
-      purpose !== 'app.bsky.graph.defs#modlist'
-    ) {
-      throw new Error('Invalid list purpose: must be curatelist or modlist')
-    }
-    const record: AppBskyGraphList.Record = {
-      purpose,
-      name,
-      description,
-      avatar: undefined,
-      createdAt: new Date().toISOString(),
-    }
-    if (avatar) {
-      const blobRes = await apilib.uploadBlob(
-        rootStore,
-        avatar.path,
-        avatar.mime,
-      )
-      record.avatar = blobRes.data.blob
-    }
-    const res = await rootStore.agent.app.bsky.graph.list.create(
-      {
-        repo: rootStore.me.did,
-      },
-      record,
-    )
-
-    // wait for the appview to update
-    await until(
-      5, // 5 tries
-      1e3, // 1s delay between tries
-      (v: GetList.Response, _e: any) => {
-        return typeof v?.data?.list.uri === 'string'
-      },
-      () =>
-        rootStore.agent.app.bsky.graph.getList({
-          list: res.uri,
-          limit: 1,
-        }),
-    )
-    return res
-  }
-
-  constructor(public rootStore: RootStoreModel, public uri: string) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  get hasContent() {
-    return this.items.length > 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get isCuratelist() {
-    return this.data?.purpose === 'app.bsky.graph.defs#curatelist'
-  }
-
-  get isModlist() {
-    return this.data?.purpose === 'app.bsky.graph.defs#modlist'
-  }
-
-  get isOwner() {
-    return this.data?.creator.did === this.rootStore.me.did
-  }
-
-  get isBlocking() {
-    return !!this.data?.viewer?.blocked
-  }
-
-  get isMuting() {
-    return !!this.data?.viewer?.muted
-  }
-
-  get isPinned() {
-    return this.rootStore.preferences.isPinnedFeed(this.uri)
-  }
-
-  get creatorDid() {
-    return this.data?.creator.did
-  }
-
-  getMembership(did: string) {
-    return this.items.find(item => item.subject.did === did)
-  }
-
-  isMember(did: string) {
-    return !!this.getMembership(did)
-  }
-
-  // public api
-  // =
-
-  async refresh() {
-    return this.loadMore(true)
-  }
-
-  loadMore = bundleAsync(async (replace: boolean = false) => {
-    if (!replace && !this.hasMore) {
-      return
-    }
-    this._xLoading(replace)
-    try {
-      await this._resolveUri()
-      const res = await this.rootStore.agent.app.bsky.graph.getList({
-        list: this.uri,
-        limit: PAGE_SIZE,
-        cursor: replace ? undefined : this.loadMoreCursor,
-      })
-      if (replace) {
-        this._replaceAll(res)
-      } else {
-        this._appendAll(res)
-      }
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(replace ? e : undefined, !replace ? e : undefined)
-    }
-  })
-
-  async loadAll() {
-    for (let i = 0; i < 1000; i++) {
-      if (!this.hasMore) {
-        break
-      }
-      await this.loadMore()
-    }
-  }
-
-  async updateMetadata({
-    name,
-    description,
-    avatar,
-  }: {
-    name: string
-    description: string
-    avatar: RNImage | null | undefined
-  }) {
-    if (!this.data) {
-      return
-    }
-    if (!this.isOwner) {
-      throw new Error('Cannot edit this list')
-    }
-    await this._resolveUri()
-
-    // get the current record
-    const {rkey} = new AtUri(this.uri)
-    const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({
-      repo: this.rootStore.me.did,
-      rkey,
-    })
-
-    // update the fields
-    record.name = name
-    record.description = description
-    if (avatar) {
-      const blobRes = await apilib.uploadBlob(
-        this.rootStore,
-        avatar.path,
-        avatar.mime,
-      )
-      record.avatar = blobRes.data.blob
-    } else if (avatar === null) {
-      record.avatar = undefined
-    }
-    return await this.rootStore.agent.com.atproto.repo.putRecord({
-      repo: this.rootStore.me.did,
-      collection: 'app.bsky.graph.list',
-      rkey,
-      record,
-    })
-  }
-
-  async delete() {
-    if (!this.data) {
-      return
-    }
-    await this._resolveUri()
-
-    // fetch all the listitem records that belong to this list
-    let cursor
-    let records: ListitemRecord[] = []
-    for (let i = 0; i < 100; i++) {
-      const res: ListitemListResponse =
-        await this.rootStore.agent.app.bsky.graph.listitem.list({
-          repo: this.rootStore.me.did,
-          cursor,
-          limit: PAGE_SIZE,
-        })
-      records = records.concat(
-        res.records.filter(record => record.value.list === this.uri),
-      )
-      cursor = res.cursor
-      if (!cursor) {
-        break
-      }
-    }
-
-    // batch delete the list and listitem records
-    const createDel = (uri: string) => {
-      const urip = new AtUri(uri)
-      return {
-        $type: 'com.atproto.repo.applyWrites#delete',
-        collection: urip.collection,
-        rkey: urip.rkey,
-      }
-    }
-    const writes = records
-      .map(record => createDel(record.uri))
-      .concat([createDel(this.uri)])
-
-    // apply in chunks
-    for (const writesChunk of chunk(writes, 10)) {
-      await this.rootStore.agent.com.atproto.repo.applyWrites({
-        repo: this.rootStore.me.did,
-        writes: writesChunk,
-      })
-    }
-
-    /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
-    this.rootStore.emitListDeleted(this.uri)
-  }
-
-  async addMember(profile: AppBskyActorDefs.ProfileViewBasic) {
-    if (this.isMember(profile.did)) {
-      return
-    }
-    await this.rootStore.agent.app.bsky.graph.listitem.create(
-      {
-        repo: this.rootStore.me.did,
-      },
-      {
-        subject: profile.did,
-        list: this.uri,
-        createdAt: new Date().toISOString(),
-      },
-    )
-    runInAction(() => {
-      this.items = this.items.concat([
-        {_reactKey: profile.did, subject: profile},
-      ])
-    })
-  }
-
-  /**
-   * Just adds to local cache; used to reflect changes affected elsewhere
-   */
-  cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) {
-    if (!this.isMember(profile.did)) {
-      this.items = this.items.concat([
-        {_reactKey: profile.did, subject: profile},
-      ])
-    }
-  }
-
-  /**
-   * Just removes from local cache; used to reflect changes affected elsewhere
-   */
-  cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) {
-    if (this.isMember(profile.did)) {
-      this.items = this.items.filter(item => item.subject.did !== profile.did)
-    }
-  }
-
-  async pin() {
-    try {
-      await this.rootStore.preferences.addPinnedFeed(this.uri)
-    } catch (error) {
-      logger.error('Failed to pin feed', {error})
-    } finally {
-      track('CustomFeed:Pin', {
-        name: this.data?.name || '',
-        uri: this.uri,
-      })
-    }
-  }
-
-  async togglePin() {
-    if (!this.isPinned) {
-      track('CustomFeed:Pin', {
-        name: this.data?.name || '',
-        uri: this.uri,
-      })
-      return this.rootStore.preferences.addPinnedFeed(this.uri)
-    } else {
-      track('CustomFeed:Unpin', {
-        name: this.data?.name || '',
-        uri: this.uri,
-      })
-      // TODO TEMPORARY
-      // lists are temporarily piggybacking on the saved/pinned feeds preferences
-      // we'll eventually replace saved feeds with the bookmarks API
-      // until then, we need to unsave lists instead of just unpin them
-      // -prf
-      // return this.rootStore.preferences.removePinnedFeed(this.uri)
-      return this.rootStore.preferences.removeSavedFeed(this.uri)
-    }
-  }
-
-  async mute() {
-    if (!this.data) {
-      return
-    }
-    await this._resolveUri()
-    await this.rootStore.agent.muteModList(this.data.uri)
-    track('Lists:Mute')
-    runInAction(() => {
-      if (this.data) {
-        const d = this.data
-        this.data = {...d, viewer: {...(d.viewer || {}), muted: true}}
-      }
-    })
-  }
-
-  async unmute() {
-    if (!this.data) {
-      return
-    }
-    await this._resolveUri()
-    await this.rootStore.agent.unmuteModList(this.data.uri)
-    track('Lists:Unmute')
-    runInAction(() => {
-      if (this.data) {
-        const d = this.data
-        this.data = {...d, viewer: {...(d.viewer || {}), muted: false}}
-      }
-    })
-  }
-
-  async block() {
-    if (!this.data) {
-      return
-    }
-    await this._resolveUri()
-    const res = await this.rootStore.agent.blockModList(this.data.uri)
-    track('Lists:Block')
-    runInAction(() => {
-      if (this.data) {
-        const d = this.data
-        this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}}
-      }
-    })
-  }
-
-  async unblock() {
-    if (!this.data || !this.data.viewer?.blocked) {
-      return
-    }
-    await this._resolveUri()
-    await this.rootStore.agent.unblockModList(this.data.uri)
-    track('Lists:Unblock')
-    runInAction(() => {
-      if (this.data) {
-        const d = this.data
-        this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}}
-      }
-    })
-  }
-
-  /**
-   * Attempt to load more again after a failure
-   */
-  async retryLoadMore() {
-    this.loadMoreError = ''
-    this.hasMore = true
-    return this.loadMore()
-  }
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-  }
-
-  _xIdle(err?: any, loadMoreErr?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = cleanError(err)
-    this.loadMoreError = cleanError(loadMoreErr)
-    if (err) {
-      logger.error('Failed to fetch user items', {error: err})
-    }
-    if (loadMoreErr) {
-      logger.error('Failed to fetch user items', {
-        error: loadMoreErr,
-      })
-    }
-  }
-
-  // helper functions
-  // =
-
-  async _resolveUri() {
-    const urip = new AtUri(this.uri)
-    if (!urip.host.startsWith('did:')) {
-      try {
-        urip.host = await apilib.resolveName(this.rootStore, urip.host)
-      } catch (e: any) {
-        runInAction(() => {
-          this.error = e.toString()
-        })
-      }
-    }
-    runInAction(() => {
-      this.uri = urip.toString()
-    })
-  }
-
-  _replaceAll(res: GetList.Response) {
-    this.items = []
-    this._appendAll(res)
-  }
-
-  _appendAll(res: GetList.Response) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-    this.data = res.data.list
-    this.items = this.items.concat(
-      res.data.items.map(item => ({...item, _reactKey: item.subject.did})),
-    )
-    if (this.data.description) {
-      this.descriptionRT = new RichText({
-        text: this.data.description,
-        facets: (this.data.descriptionFacets || [])?.slice(),
-      })
-    } else {
-      this.descriptionRT = null
-    }
-  }
-}
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 14362ceec..2abb9bfb5 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -158,7 +158,7 @@ export class ProfileModel {
       existing.description = updates.description
       if (newUserAvatar) {
         const res = await apilib.uploadBlob(
-          this.rootStore,
+          this.rootStore.agent,
           newUserAvatar.path,
           newUserAvatar.mime,
         )
@@ -168,7 +168,7 @@ export class ProfileModel {
       }
       if (newUserBanner) {
         const res = await apilib.uploadBlob(
-          this.rootStore,
+          this.rootStore.agent,
           newUserBanner.path,
           newUserBanner.mime,
         )
diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts
deleted file mode 100644
index eb6291637..000000000
--- a/src/state/models/lists/lists-list.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
-import {RootStoreModel} from '../root-store'
-import {cleanError} from 'lib/strings/errors'
-import {bundleAsync} from 'lib/async/bundle'
-import {accumulate} from 'lib/async/accumulate'
-import {logger} from '#/logger'
-
-const PAGE_SIZE = 30
-
-export class ListsListModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  loadMoreError = ''
-  hasMore = true
-  loadMoreCursor?: string
-
-  // data
-  lists: GraphDefs.ListView[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    public source: 'mine' | 'my-curatelists' | 'my-modlists' | string,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  get hasContent() {
-    return this.lists.length > 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get curatelists() {
-    return this.lists.filter(
-      list => list.purpose === 'app.bsky.graph.defs#curatelist',
-    )
-  }
-
-  get isCuratelistsEmpty() {
-    return this.hasLoaded && this.curatelists.length === 0
-  }
-
-  get modlists() {
-    return this.lists.filter(
-      list => list.purpose === 'app.bsky.graph.defs#modlist',
-    )
-  }
-
-  get isModlistsEmpty() {
-    return this.hasLoaded && this.modlists.length === 0
-  }
-
-  /**
-   * Removes posts from the feed upon deletion.
-   */
-  onListDeleted(uri: string) {
-    this.lists = this.lists.filter(l => l.uri !== uri)
-  }
-
-  // public api
-  // =
-
-  /**
-   * Register any event listeners. Returns a cleanup function.
-   */
-  registerListeners() {
-    const sub = this.rootStore.onListDeleted(this.onListDeleted.bind(this))
-    return () => sub.remove()
-  }
-
-  async refresh() {
-    return this.loadMore(true)
-  }
-
-  loadMore = bundleAsync(async (replace: boolean = false) => {
-    if (!replace && !this.hasMore) {
-      return
-    }
-    this._xLoading(replace)
-    try {
-      let cursor: string | undefined
-      let lists: GraphDefs.ListView[] = []
-      if (
-        this.source === 'mine' ||
-        this.source === 'my-curatelists' ||
-        this.source === 'my-modlists'
-      ) {
-        const promises = [
-          accumulate(cursor =>
-            this.rootStore.agent.app.bsky.graph
-              .getLists({
-                actor: this.rootStore.me.did,
-                cursor,
-                limit: 50,
-              })
-              .then(res => ({cursor: res.data.cursor, items: res.data.lists})),
-          ),
-        ]
-        if (this.source === 'my-modlists') {
-          promises.push(
-            accumulate(cursor =>
-              this.rootStore.agent.app.bsky.graph
-                .getListMutes({
-                  cursor,
-                  limit: 50,
-                })
-                .then(res => ({
-                  cursor: res.data.cursor,
-                  items: res.data.lists,
-                })),
-            ),
-          )
-          promises.push(
-            accumulate(cursor =>
-              this.rootStore.agent.app.bsky.graph
-                .getListBlocks({
-                  cursor,
-                  limit: 50,
-                })
-                .then(res => ({
-                  cursor: res.data.cursor,
-                  items: res.data.lists,
-                })),
-            ),
-          )
-        }
-        const resultset = await Promise.all(promises)
-        for (const res of resultset) {
-          for (let list of res) {
-            if (
-              this.source === 'my-curatelists' &&
-              list.purpose !== 'app.bsky.graph.defs#curatelist'
-            ) {
-              continue
-            }
-            if (
-              this.source === 'my-modlists' &&
-              list.purpose !== 'app.bsky.graph.defs#modlist'
-            ) {
-              continue
-            }
-            if (!lists.find(l => l.uri === list.uri)) {
-              lists.push(list)
-            }
-          }
-        }
-      } else {
-        const res = await this.rootStore.agent.app.bsky.graph.getLists({
-          actor: this.source,
-          limit: PAGE_SIZE,
-          cursor: replace ? undefined : this.loadMoreCursor,
-        })
-        lists = res.data.lists
-        cursor = res.data.cursor
-      }
-      if (replace) {
-        this._replaceAll({lists, cursor})
-      } else {
-        this._appendAll({lists, cursor})
-      }
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(replace ? e : undefined, !replace ? e : undefined)
-    }
-  })
-
-  /**
-   * Attempt to load more again after a failure
-   */
-  async retryLoadMore() {
-    this.loadMoreError = ''
-    this.hasMore = true
-    return this.loadMore()
-  }
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-  }
-
-  _xIdle(err?: any, loadMoreErr?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = cleanError(err)
-    this.loadMoreError = cleanError(loadMoreErr)
-    if (err) {
-      logger.error('Failed to fetch user lists', {error: err})
-    }
-    if (loadMoreErr) {
-      logger.error('Failed to fetch user lists', {
-        error: loadMoreErr,
-      })
-    }
-  }
-
-  // helper functions
-  // =
-
-  _replaceAll({
-    lists,
-    cursor,
-  }: {
-    lists: GraphDefs.ListView[]
-    cursor: string | undefined
-  }) {
-    this.lists = []
-    this._appendAll({lists, cursor})
-  }
-
-  _appendAll({
-    lists,
-    cursor,
-  }: {
-    lists: GraphDefs.ListView[]
-    cursor: string | undefined
-  }) {
-    this.loadMoreCursor = cursor
-    this.hasMore = !!this.loadMoreCursor
-    this.lists = this.lists.concat(
-      lists.map(list => ({...list, _reactKey: list.uri})),
-    )
-  }
-}
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 0ef592928..d6ea0c084 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -2,7 +2,6 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {ProfileModel} from '../content/profile'
 import {ActorFeedsModel} from '../lists/actor-feeds'
-import {ListsListModel} from '../lists/lists-list'
 import {logger} from '#/logger'
 
 export enum Sections {
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
new file mode 100644
index 000000000..18abb6314
--- /dev/null
+++ b/src/state/queries/actor-autocomplete.ts
@@ -0,0 +1,66 @@
+import {AppBskyActorDefs} from '@atproto/api'
+import {useQuery} from '@tanstack/react-query'
+import {useSession} from '../session'
+import {useMyFollowsQuery} from './my-follows'
+
+export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
+
+export function useActorAutocompleteQuery(prefix: string) {
+  const {agent} = useSession()
+  const {data: follows, isFetching} = useMyFollowsQuery()
+  return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
+    queryKey: RQKEY(prefix || ''),
+    async queryFn() {
+      const res = await agent.searchActorsTypeahead({
+        term: prefix,
+        limit: 8,
+      })
+      return computeSuggestions(prefix, follows, res.data.actors)
+    },
+    enabled: !isFetching && !!prefix,
+  })
+}
+
+function computeSuggestions(
+  prefix: string,
+  follows: AppBskyActorDefs.ProfileViewBasic[] = [],
+  searched: AppBskyActorDefs.ProfileViewBasic[] = [],
+) {
+  if (prefix) {
+    const items: AppBskyActorDefs.ProfileViewBasic[] = []
+    for (const item of follows) {
+      if (prefixMatch(prefix, item)) {
+        items.push(item)
+      }
+      if (items.length >= 8) {
+        break
+      }
+    }
+    for (const item of searched) {
+      if (!items.find(item2 => item2.handle === item.handle)) {
+        items.push({
+          did: item.did,
+          handle: item.handle,
+          displayName: item.displayName,
+          avatar: item.avatar,
+        })
+      }
+    }
+    return items
+  } else {
+    return follows
+  }
+}
+
+function prefixMatch(
+  prefix: string,
+  info: AppBskyActorDefs.ProfileViewBasic,
+): boolean {
+  if (info.handle.includes(prefix)) {
+    return true
+  }
+  if (info.displayName?.toLocaleLowerCase().includes(prefix)) {
+    return true
+  }
+  return false
+}
diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts
new file mode 100644
index 000000000..ec5daec90
--- /dev/null
+++ b/src/state/queries/list-members.ts
@@ -0,0 +1,31 @@
+import {AppBskyGraphGetList} from '@atproto/api'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+const PAGE_SIZE = 30
+type RQPageParam = string | undefined
+
+export const RQKEY = (uri: string) => ['list-members', uri]
+
+export function useListMembersQuery(uri: string) {
+  const {agent} = useSession()
+  return useInfiniteQuery<
+    AppBskyGraphGetList.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetList.OutputSchema>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(uri),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      const res = await agent.app.bsky.graph.getList({
+        list: uri,
+        limit: PAGE_SIZE,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts
new file mode 100644
index 000000000..f128c5867
--- /dev/null
+++ b/src/state/queries/list-memberships.ts
@@ -0,0 +1,190 @@
+/**
+ * NOTE
+ *
+ * This query is a temporary solution to our lack of server API for
+ * querying user membership in an API. It is extremely inefficient.
+ *
+ * THIS SHOULD ONLY BE USED IN MODALS FOR MODIFYING A USER'S LIST MEMBERSHIP!
+ * Use the list-members query for rendering a list's members.
+ *
+ * It works by fetching *all* of the user's list item records and querying
+ * or manipulating that cache. For users with large lists, it will fall
+ * down completely, so be very conservative about how you use it.
+ *
+ * -prf
+ */
+
+import {AtUri} from '@atproto/api'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+import {useSession} from '../session'
+import {RQKEY as LIST_MEMBERS_RQKEY} from './list-members'
+
+// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records
+const SANITY_PAGE_LIMIT = 1000
+const PAGE_SIZE = 100
+// ...which comes 100,000k list members
+
+export const RQKEY = () => ['list-memberships']
+
+export interface ListMembersip {
+  membershipUri: string
+  listUri: string
+  actorDid: string
+}
+
+/**
+ * This API is dangerous! Read the note above!
+ */
+export function useDangerousListMembershipsQuery() {
+  const {agent, currentAccount} = useSession()
+  return useQuery<ListMembersip[]>({
+    queryKey: RQKEY(),
+    async queryFn() {
+      if (!currentAccount) {
+        return []
+      }
+      let cursor
+      let arr: ListMembersip[] = []
+      for (let i = 0; i < SANITY_PAGE_LIMIT; i++) {
+        const res = await agent.app.bsky.graph.listitem.list({
+          repo: currentAccount.did,
+          limit: PAGE_SIZE,
+          cursor,
+        })
+        arr = arr.concat(
+          res.records.map(r => ({
+            membershipUri: r.uri,
+            listUri: r.value.list,
+            actorDid: r.value.subject,
+          })),
+        )
+        cursor = res.cursor
+        if (!cursor) {
+          break
+        }
+      }
+      return arr
+    },
+  })
+}
+
+/**
+ * Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record)
+ */
+export function getMembership(
+  memberships: ListMembersip[] | undefined,
+  list: string,
+  actor: string,
+): string | false | undefined {
+  if (!memberships) {
+    return undefined
+  }
+  const membership = memberships.find(
+    m => m.listUri === list && m.actorDid === actor,
+  )
+  return membership ? membership.membershipUri : false
+}
+
+export function useListMembershipAddMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<
+    {uri: string; cid: string},
+    Error,
+    {listUri: string; actorDid: string}
+  >({
+    mutationFn: async ({listUri, actorDid}) => {
+      if (!currentAccount) {
+        throw new Error('Not logged in')
+      }
+      const res = await agent.app.bsky.graph.listitem.create(
+        {repo: currentAccount.did},
+        {
+          subject: actorDid,
+          list: listUri,
+          createdAt: new Date().toISOString(),
+        },
+      )
+      // TODO
+      // we need to wait for appview to update, but there's not an efficient
+      // query for that, so we use a timeout below
+      // -prf
+      return res
+    },
+    onSuccess(data, variables) {
+      // manually update the cache; a refetch is too expensive
+      let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY())
+      if (memberships) {
+        memberships = memberships
+          // avoid dups
+          .filter(
+            m =>
+              !(
+                m.actorDid === variables.actorDid &&
+                m.listUri === variables.listUri
+              ),
+          )
+          .concat([
+            {
+              ...variables,
+              membershipUri: data.uri,
+            },
+          ])
+        queryClient.setQueryData(RQKEY(), memberships)
+      }
+      // invalidate the members queries (used for rendering the listings)
+      // use a timeout to wait for the appview (see above)
+      setTimeout(() => {
+        queryClient.invalidateQueries({
+          queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
+        })
+      }, 1e3)
+    },
+  })
+}
+
+export function useListMembershipRemoveMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<
+    void,
+    Error,
+    {listUri: string; actorDid: string; membershipUri: string}
+  >({
+    mutationFn: async ({membershipUri}) => {
+      if (!currentAccount) {
+        throw new Error('Not logged in')
+      }
+      const membershipUrip = new AtUri(membershipUri)
+      await agent.app.bsky.graph.listitem.delete({
+        repo: currentAccount.did,
+        rkey: membershipUrip.rkey,
+      })
+      // TODO
+      // we need to wait for appview to update, but there's not an efficient
+      // query for that, so we use a timeout below
+      // -prf
+    },
+    onSuccess(data, variables) {
+      // manually update the cache; a refetch is too expensive
+      let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY())
+      if (memberships) {
+        memberships = memberships.filter(
+          m =>
+            !(
+              m.actorDid === variables.actorDid &&
+              m.listUri === variables.listUri
+            ),
+        )
+        queryClient.setQueryData(RQKEY(), memberships)
+      }
+      // invalidate the members queries (used for rendering the listings)
+      // use a timeout to wait for the appview (see above)
+      setTimeout(() => {
+        queryClient.invalidateQueries({
+          queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
+        })
+      }, 1e3)
+    },
+  })
+}
diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts
new file mode 100644
index 000000000..4a46a4fbe
--- /dev/null
+++ b/src/state/queries/list.ts
@@ -0,0 +1,285 @@
+import {
+  AtUri,
+  AppBskyGraphGetList,
+  AppBskyGraphList,
+  AppBskyGraphDefs,
+  BskyAgent,
+} from '@atproto/api'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
+import chunk from 'lodash.chunk'
+import {useSession} from '../session'
+import {invalidate as invalidateMyLists} from './my-lists'
+import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
+import {uploadBlob} from '#/lib/api'
+import {until} from '#/lib/async/until'
+
+export const RQKEY = (uri: string) => ['list', uri]
+
+export function useListQuery(uri?: string) {
+  const {agent} = useSession()
+  return useQuery<AppBskyGraphDefs.ListView, Error>({
+    queryKey: RQKEY(uri || ''),
+    async queryFn() {
+      if (!uri) {
+        throw new Error('URI not provided')
+      }
+      const res = await agent.app.bsky.graph.getList({
+        list: uri,
+        limit: 1,
+      })
+      return res.data.list
+    },
+    enabled: !!uri,
+  })
+}
+
+export interface ListCreateMutateParams {
+  purpose: string
+  name: string
+  description: string
+  avatar: RNImage | null | undefined
+}
+export function useListCreateMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
+    {
+      async mutationFn({purpose, name, description, avatar}) {
+        if (!currentAccount) {
+          throw new Error('Not logged in')
+        }
+        if (
+          purpose !== 'app.bsky.graph.defs#curatelist' &&
+          purpose !== 'app.bsky.graph.defs#modlist'
+        ) {
+          throw new Error('Invalid list purpose: must be curatelist or modlist')
+        }
+        const record: AppBskyGraphList.Record = {
+          purpose,
+          name,
+          description,
+          avatar: undefined,
+          createdAt: new Date().toISOString(),
+        }
+        if (avatar) {
+          const blobRes = await uploadBlob(agent, avatar.path, avatar.mime)
+          record.avatar = blobRes.data.blob
+        }
+        const res = await agent.app.bsky.graph.list.create(
+          {
+            repo: currentAccount.did,
+          },
+          record,
+        )
+
+        // wait for the appview to update
+        await whenAppViewReady(
+          agent,
+          res.uri,
+          (v: AppBskyGraphGetList.Response) => {
+            return typeof v?.data?.list.uri === 'string'
+          },
+        )
+        return res
+      },
+      onSuccess() {
+        invalidateMyLists(queryClient)
+        queryClient.invalidateQueries({
+          queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
+        })
+      },
+    },
+  )
+}
+
+export interface ListMetadataMutateParams {
+  uri: string
+  name: string
+  description: string
+  avatar: RNImage | null | undefined
+}
+export function useListMetadataMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<
+    {uri: string; cid: string},
+    Error,
+    ListMetadataMutateParams
+  >({
+    async mutationFn({uri, name, description, avatar}) {
+      const {hostname, rkey} = new AtUri(uri)
+      if (!currentAccount) {
+        throw new Error('Not logged in')
+      }
+      if (currentAccount.did !== hostname) {
+        throw new Error('You do not own this list')
+      }
+
+      // get the current record
+      const {value: record} = await agent.app.bsky.graph.list.get({
+        repo: currentAccount.did,
+        rkey,
+      })
+
+      // update the fields
+      record.name = name
+      record.description = description
+      if (avatar) {
+        const blobRes = await uploadBlob(agent, avatar.path, avatar.mime)
+        record.avatar = blobRes.data.blob
+      } else if (avatar === null) {
+        record.avatar = undefined
+      }
+      const res = (
+        await agent.com.atproto.repo.putRecord({
+          repo: currentAccount.did,
+          collection: 'app.bsky.graph.list',
+          rkey,
+          record,
+        })
+      ).data
+
+      // wait for the appview to update
+      await whenAppViewReady(
+        agent,
+        res.uri,
+        (v: AppBskyGraphGetList.Response) => {
+          const list = v.data.list
+          return (
+            list.name === record.name && list.description === record.description
+          )
+        },
+      )
+      return res
+    },
+    onSuccess(data, variables) {
+      invalidateMyLists(queryClient)
+      queryClient.invalidateQueries({
+        queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
+      })
+      queryClient.invalidateQueries({
+        queryKey: RQKEY(variables.uri),
+      })
+    },
+  })
+}
+
+export function useListDeleteMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<void, Error, {uri: string}>({
+    mutationFn: async ({uri}) => {
+      if (!currentAccount) {
+        return
+      }
+      // fetch all the listitem records that belong to this list
+      let cursor
+      let listitemRecordUris: string[] = []
+      for (let i = 0; i < 100; i++) {
+        const res = await agent.app.bsky.graph.listitem.list({
+          repo: currentAccount.did,
+          cursor,
+          limit: 100,
+        })
+        listitemRecordUris = listitemRecordUris.concat(
+          res.records
+            .filter(record => record.value.list === uri)
+            .map(record => record.uri),
+        )
+        cursor = res.cursor
+        if (!cursor) {
+          break
+        }
+      }
+
+      // batch delete the list and listitem records
+      const createDel = (uri: string) => {
+        const urip = new AtUri(uri)
+        return {
+          $type: 'com.atproto.repo.applyWrites#delete',
+          collection: urip.collection,
+          rkey: urip.rkey,
+        }
+      }
+      const writes = listitemRecordUris
+        .map(uri => createDel(uri))
+        .concat([createDel(uri)])
+
+      // apply in chunks
+      for (const writesChunk of chunk(writes, 10)) {
+        await agent.com.atproto.repo.applyWrites({
+          repo: currentAccount.did,
+          writes: writesChunk,
+        })
+      }
+
+      // wait for the appview to update
+      await whenAppViewReady(agent, uri, (v: AppBskyGraphGetList.Response) => {
+        return !v?.success
+      })
+    },
+    onSuccess() {
+      invalidateMyLists(queryClient)
+      queryClient.invalidateQueries({
+        queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
+      })
+      // TODO!! /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
+    },
+  })
+}
+
+export function useListMuteMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<void, Error, {uri: string; mute: boolean}>({
+    mutationFn: async ({uri, mute}) => {
+      if (mute) {
+        await agent.muteModList(uri)
+      } else {
+        await agent.unmuteModList(uri)
+      }
+    },
+    onSuccess(data, variables) {
+      queryClient.invalidateQueries({
+        queryKey: RQKEY(variables.uri),
+      })
+    },
+  })
+}
+
+export function useListBlockMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<void, Error, {uri: string; block: boolean}>({
+    mutationFn: async ({uri, block}) => {
+      if (block) {
+        await agent.blockModList(uri)
+      } else {
+        await agent.unblockModList(uri)
+      }
+    },
+    onSuccess(data, variables) {
+      queryClient.invalidateQueries({
+        queryKey: RQKEY(variables.uri),
+      })
+    },
+  })
+}
+
+async function whenAppViewReady(
+  agent: BskyAgent,
+  uri: string,
+  fn: (res: AppBskyGraphGetList.Response) => boolean,
+) {
+  await until(
+    5, // 5 tries
+    1e3, // 1s delay between tries
+    fn,
+    () =>
+      agent.app.bsky.graph.getList({
+        list: uri,
+        limit: 1,
+      }),
+  )
+}
diff --git a/src/state/queries/my-follows.ts b/src/state/queries/my-follows.ts
new file mode 100644
index 000000000..ad6cf837d
--- /dev/null
+++ b/src/state/queries/my-follows.ts
@@ -0,0 +1,43 @@
+import {AppBskyActorDefs} from '@atproto/api'
+import {useQuery} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records
+const SANITY_PAGE_LIMIT = 1000
+const PAGE_SIZE = 100
+// ...which comes 10,000k follows
+
+export const RQKEY = () => ['my-follows']
+
+export function useMyFollowsQuery() {
+  const {agent, currentAccount} = useSession()
+  return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
+    queryKey: RQKEY(),
+    async queryFn() {
+      if (!currentAccount) {
+        return []
+      }
+      let cursor
+      let arr: AppBskyActorDefs.ProfileViewBasic[] = []
+      for (let i = 0; i < SANITY_PAGE_LIMIT; i++) {
+        const res = await agent.getFollows({
+          actor: currentAccount.did,
+          cursor,
+          limit: PAGE_SIZE,
+        })
+        // TODO
+        // res.data.follows = res.data.follows.filter(
+        //   profile =>
+        //     !moderateProfile(profile, this.rootStore.preferences.moderationOpts)
+        //       .account.filter,
+        // )
+        arr = arr.concat(res.data.follows)
+        if (!res.data.cursor) {
+          break
+        }
+        cursor = res.data.cursor
+      }
+      return arr
+    },
+  })
+}
diff --git a/src/state/queries/my-lists.ts b/src/state/queries/my-lists.ts
new file mode 100644
index 000000000..d412cff02
--- /dev/null
+++ b/src/state/queries/my-lists.ts
@@ -0,0 +1,89 @@
+import {AppBskyGraphDefs} from '@atproto/api'
+import {useQuery, QueryClient} from '@tanstack/react-query'
+import {accumulate} from 'lib/async/accumulate'
+import {useSession} from '../session'
+
+export type MyListsFilter = 'all' | 'curate' | 'mod'
+export const RQKEY = (filter: MyListsFilter) => ['my-lists', filter]
+
+export function useMyListsQuery(filter: MyListsFilter) {
+  const {agent, currentAccount} = useSession()
+  return useQuery<AppBskyGraphDefs.ListView[]>({
+    queryKey: RQKEY(filter),
+    async queryFn() {
+      let lists: AppBskyGraphDefs.ListView[] = []
+      const promises = [
+        accumulate(cursor =>
+          agent.app.bsky.graph
+            .getLists({
+              actor: currentAccount!.did,
+              cursor,
+              limit: 50,
+            })
+            .then(res => ({
+              cursor: res.data.cursor,
+              items: res.data.lists,
+            })),
+        ),
+      ]
+      if (filter === 'all' || filter === 'mod') {
+        promises.push(
+          accumulate(cursor =>
+            agent.app.bsky.graph
+              .getListMutes({
+                cursor,
+                limit: 50,
+              })
+              .then(res => ({
+                cursor: res.data.cursor,
+                items: res.data.lists,
+              })),
+          ),
+        )
+        promises.push(
+          accumulate(cursor =>
+            agent.app.bsky.graph
+              .getListBlocks({
+                cursor,
+                limit: 50,
+              })
+              .then(res => ({
+                cursor: res.data.cursor,
+                items: res.data.lists,
+              })),
+          ),
+        )
+      }
+      const resultset = await Promise.all(promises)
+      for (const res of resultset) {
+        for (let list of res) {
+          if (
+            filter === 'curate' &&
+            list.purpose !== 'app.bsky.graph.defs#curatelist'
+          ) {
+            continue
+          }
+          if (
+            filter === 'mod' &&
+            list.purpose !== 'app.bsky.graph.defs#modlist'
+          ) {
+            continue
+          }
+          if (!lists.find(l => l.uri === list.uri)) {
+            lists.push(list)
+          }
+        }
+      }
+      return lists
+    },
+    enabled: !!currentAccount,
+  })
+}
+
+export function invalidate(qc: QueryClient, filter?: MyListsFilter) {
+  if (filter) {
+    qc.invalidateQueries({queryKey: RQKEY(filter)})
+  } else {
+    qc.invalidateQueries({queryKey: ['my-lists']})
+  }
+}
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
new file mode 100644
index 000000000..a277a6d61
--- /dev/null
+++ b/src/state/queries/profile-lists.ts
@@ -0,0 +1,31 @@
+import {AppBskyGraphGetLists} from '@atproto/api'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+const PAGE_SIZE = 30
+type RQPageParam = string | undefined
+
+export const RQKEY = (did: string) => ['profile-lists', did]
+
+export function useProfileListsQuery(did: string) {
+  const {agent} = useSession()
+  return useInfiniteQuery<
+    AppBskyGraphGetLists.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetLists.OutputSchema>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(did),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      const res = await agent.app.bsky.graph.getLists({
+        actor: did,
+        limit: PAGE_SIZE,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx
index a481902d8..774e9e916 100644
--- a/src/view/com/lists/ListCard.tsx
+++ b/src/view/com/lists/ListCard.tsx
@@ -7,7 +7,7 @@ import {RichText as RichTextCom} from '../util/text/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
+import {useSession} from '#/state/session'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
@@ -28,7 +28,7 @@ export const ListCard = ({
   style?: StyleProp<ViewStyle>
 }) => {
   const pal = usePalette('default')
-  const store = useStores()
+  const {currentAccount} = useSession()
 
   const rkey = React.useMemo(() => {
     try {
@@ -80,7 +80,7 @@ export const ListCard = ({
             {list.purpose === 'app.bsky.graph.defs#modlist' &&
               'Moderation list '}
             by{' '}
-            {list.creator.did === store.me.did
+            {list.creator.did === currentAccount?.did
               ? 'you'
               : sanitizeHandle(list.creator.handle, '@')}
           </Text>
diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListMembers.tsx
index cf6fd3b42..4a25c53e6 100644
--- a/src/view/com/lists/ListItems.tsx
+++ b/src/view/com/lists/ListMembers.tsx
@@ -9,27 +9,28 @@ import {
 } from 'react-native'
 import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
 import {FlatList} from '../util/Views'
-import {observer} from 'mobx-react-lite'
 import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {ProfileCard} from '../profile/ProfileCard'
 import {Button} from '../util/forms/Button'
-import {ListModel} from 'state/models/content/list'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useListMembersQuery} from '#/state/queries/list-members'
 import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {logger} from '#/logger'
 import {useModalControls} from '#/state/modals'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {useSession} from '#/state/session'
+import {cleanError} from '#/lib/strings/errors'
 
 const LOADING_ITEM = {_reactKey: '__loading__'}
 const EMPTY_ITEM = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
-export const ListItems = observer(function ListItemsImpl({
+export function ListMembers({
   list,
   style,
   scrollElRef,
@@ -42,7 +43,7 @@ export const ListItems = observer(function ListItemsImpl({
   headerOffset = 0,
   desktopFixedHeightOffset,
 }: {
-  list: ListModel
+  list: string
   style?: StyleProp<ViewStyle>
   scrollElRef?: MutableRefObject<FlatList<any> | null>
   onScroll: OnScrollHandler
@@ -59,33 +60,43 @@ export const ListItems = observer(function ListItemsImpl({
   const [isRefreshing, setIsRefreshing] = React.useState(false)
   const {isMobile} = useWebMediaQueries()
   const {openModal} = useModalControls()
+  const {currentAccount} = useSession()
 
-  const data = React.useMemo(() => {
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isError,
+    error,
+    refetch,
+    fetchNextPage,
+    hasNextPage,
+  } = useListMembersQuery(list)
+  const isEmpty = !isFetching && !data?.pages[0].items.length
+  const isOwner =
+    currentAccount && data?.pages[0].list.creator.did === currentAccount.did
+
+  const items = React.useMemo(() => {
     let items: any[] = []
-    if (list.hasLoaded) {
-      if (list.hasError) {
+    if (isFetched) {
+      if (isEmpty && isError) {
         items = items.concat([ERROR_ITEM])
       }
-      if (list.isEmpty) {
+      if (isEmpty) {
         items = items.concat([EMPTY_ITEM])
-      } else {
-        items = items.concat(list.items)
+      } else if (data) {
+        for (const page of data.pages) {
+          items = items.concat(page.items)
+        }
       }
-      if (list.loadMoreError) {
+      if (!isEmpty && isError) {
         items = items.concat([LOAD_MORE_ERROR_ITEM])
       }
-    } else if (list.isLoading) {
+    } else if (isFetching) {
       items = items.concat([LOADING_ITEM])
     }
     return items
-  }, [
-    list.hasError,
-    list.hasLoaded,
-    list.isLoading,
-    list.isEmpty,
-    list.items,
-    list.loadMoreError,
-  ])
+  }, [isFetched, isEmpty, isError, data, isFetching])
 
   // events
   // =
@@ -94,25 +105,26 @@ export const ListItems = observer(function ListItemsImpl({
     track('Lists:onRefresh')
     setIsRefreshing(true)
     try {
-      await list.refresh()
+      await refetch()
     } catch (err) {
       logger.error('Failed to refresh lists', {error: err})
     }
     setIsRefreshing(false)
-  }, [list, track, setIsRefreshing])
+  }, [refetch, track, setIsRefreshing])
 
   const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
     track('Lists:onEndReached')
     try {
-      await list.loadMore()
+      await fetchNextPage()
     } catch (err) {
       logger.error('Failed to load more lists', {error: err})
     }
-  }, [list, track])
+  }, [isFetching, hasNextPage, isError, fetchNextPage, track])
 
   const onPressRetryLoadMore = React.useCallback(() => {
-    list.retryLoadMore()
-  }, [list])
+    fetchNextPage()
+  }, [fetchNextPage])
 
   const onPressEditMembership = React.useCallback(
     (profile: AppBskyActorDefs.ProfileViewBasic) => {
@@ -120,19 +132,9 @@ export const ListItems = observer(function ListItemsImpl({
         name: 'user-add-remove-lists',
         subject: profile.did,
         displayName: profile.displayName || profile.handle,
-        onAdd(listUri: string) {
-          if (listUri === list.uri) {
-            list.cacheAddMember(profile)
-          }
-        },
-        onRemove(listUri: string) {
-          if (listUri === list.uri) {
-            list.cacheRemoveMember(profile)
-          }
-        },
       })
     },
-    [openModal, list],
+    [openModal],
   )
 
   // rendering
@@ -140,7 +142,7 @@ export const ListItems = observer(function ListItemsImpl({
 
   const renderMemberButton = React.useCallback(
     (profile: AppBskyActorDefs.ProfileViewBasic) => {
-      if (!list.isOwner) {
+      if (!isOwner) {
         return null
       }
       return (
@@ -152,7 +154,7 @@ export const ListItems = observer(function ListItemsImpl({
         />
       )
     },
-    [list, onPressEditMembership],
+    [isOwner, onPressEditMembership],
   )
 
   const renderItem = React.useCallback(
@@ -162,7 +164,7 @@ export const ListItems = observer(function ListItemsImpl({
       } else if (item === ERROR_ITEM) {
         return (
           <ErrorMessage
-            message={list.error}
+            message={cleanError(error)}
             onPressTryAgain={onPressTryAgain}
           />
         )
@@ -190,7 +192,7 @@ export const ListItems = observer(function ListItemsImpl({
     [
       renderMemberButton,
       renderEmptyState,
-      list.error,
+      error,
       onPressTryAgain,
       onPressRetryLoadMore,
       isMobile,
@@ -200,10 +202,10 @@ export const ListItems = observer(function ListItemsImpl({
   const Footer = React.useCallback(
     () => (
       <View style={{paddingTop: 20, paddingBottom: 200}}>
-        {list.isLoading && <ActivityIndicator />}
+        {isFetching && <ActivityIndicator />}
       </View>
     ),
-    [list.isLoading],
+    [isFetching],
   )
 
   const scrollHandler = useAnimatedScrollHandler(onScroll)
@@ -212,8 +214,8 @@ export const ListItems = observer(function ListItemsImpl({
       <FlatList
         testID={testID ? `${testID}-flatlist` : undefined}
         ref={scrollElRef}
-        data={data}
-        keyExtractor={(item: any) => item._reactKey}
+        data={items}
+        keyExtractor={(item: any) => item.uri || item._reactKey}
         renderItem={renderItem}
         ListHeaderComponent={renderHeader}
         ListFooterComponent={Footer}
@@ -241,4 +243,4 @@ export const ListItems = observer(function ListItemsImpl({
       />
     </View>
   )
-})
+}
diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx
index 2883a31d5..100e0d609 100644
--- a/src/view/com/lists/ListsList.tsx
+++ b/src/view/com/lists/ListsList.tsx
@@ -8,68 +8,59 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
 import {ListCard} from './ListCard'
+import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {Text} from '../util/text/Text'
-import {ListsListModel} from 'state/models/lists/lists-list'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {FlatList} from '../util/Views'
 import {s} from 'lib/styles'
 import {logger} from '#/logger'
 import {Trans} from '@lingui/macro'
+import {cleanError} from '#/lib/strings/errors'
 
 const LOADING = {_reactKey: '__loading__'}
 const EMPTY = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
-export const ListsList = observer(function ListsListImpl({
-  listsList,
+export function ListsList({
+  filter,
   inline,
   style,
-  onPressTryAgain,
   renderItem,
   testID,
 }: {
-  listsList: ListsListModel
+  filter: MyListsFilter
   inline?: boolean
   style?: StyleProp<ViewStyle>
-  onPressTryAgain?: () => void
   renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element
   testID?: string
 }) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
   const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const {data, isFetching, isFetched, isError, error, refetch} =
+    useMyListsQuery(filter)
+  const isEmpty = !isFetching && !data?.length
 
-  const data = React.useMemo(() => {
+  const items = React.useMemo(() => {
     let items: any[] = []
-    if (listsList.hasError) {
+    if (isError && isEmpty) {
       items = items.concat([ERROR_ITEM])
     }
-    if (!listsList.hasLoaded && listsList.isLoading) {
+    if (!isFetched && isFetching) {
       items = items.concat([LOADING])
-    } else if (listsList.isEmpty) {
+    } else if (isEmpty) {
       items = items.concat([EMPTY])
     } else {
-      items = items.concat(listsList.lists)
-    }
-    if (listsList.loadMoreError) {
-      items = items.concat([LOAD_MORE_ERROR_ITEM])
+      items = items.concat(data)
     }
     return items
-  }, [
-    listsList.hasError,
-    listsList.hasLoaded,
-    listsList.isLoading,
-    listsList.lists,
-    listsList.isEmpty,
-    listsList.loadMoreError,
-  ])
+  }, [isError, isEmpty, isFetched, isFetching, data])
 
   // events
   // =
@@ -78,25 +69,12 @@ export const ListsList = observer(function ListsListImpl({
     track('Lists:onRefresh')
     setIsRefreshing(true)
     try {
-      await listsList.refresh()
+      await refetch()
     } catch (err) {
       logger.error('Failed to refresh lists', {error: err})
     }
     setIsRefreshing(false)
-  }, [listsList, track, setIsRefreshing])
-
-  const onEndReached = React.useCallback(async () => {
-    track('Lists:onEndReached')
-    try {
-      await listsList.loadMore()
-    } catch (err) {
-      logger.error('Failed to load more lists', {error: err})
-    }
-  }, [listsList, track])
-
-  const onPressRetryLoadMore = React.useCallback(() => {
-    listsList.retryLoadMore()
-  }, [listsList])
+  }, [refetch, track, setIsRefreshing])
 
   // rendering
   // =
@@ -116,15 +94,15 @@ export const ListsList = observer(function ListsListImpl({
       } else if (item === ERROR_ITEM) {
         return (
           <ErrorMessage
-            message={listsList.error}
-            onPressTryAgain={onPressTryAgain}
+            message={cleanError(error)}
+            onPressTryAgain={onRefresh}
           />
         )
       } else if (item === LOAD_MORE_ERROR_ITEM) {
         return (
           <LoadMoreRetryBtn
             label="There was an issue fetching your lists. Tap here to try again."
-            onPress={onPressRetryLoadMore}
+            onPress={onRefresh}
           />
         )
       } else if (item === LOADING) {
@@ -144,16 +122,16 @@ export const ListsList = observer(function ListsListImpl({
         />
       )
     },
-    [listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal],
+    [error, onRefresh, renderItem, pal],
   )
 
   const FlatListCom = inline ? RNFlatList : FlatList
   return (
     <View testID={testID} style={style}>
-      {data.length > 0 && (
+      {items.length > 0 && (
         <FlatListCom
           testID={testID ? `${testID}-flatlist` : undefined}
-          data={data}
+          data={items}
           keyExtractor={(item: any) => item._reactKey}
           renderItem={renderItemInner}
           refreshControl={
@@ -165,8 +143,6 @@ export const ListsList = observer(function ListsListImpl({
             />
           }
           contentContainerStyle={[s.contentContainer]}
-          onEndReached={onEndReached}
-          onEndReachedThreshold={0.6}
           removeClippedSubviews={true}
           // @ts-ignore our .web version only -prf
           desktopFixedHeight
@@ -174,7 +150,7 @@ export const ListsList = observer(function ListsListImpl({
       )}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   item: {
diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx
index cfd0f7569..8d13cdf2f 100644
--- a/src/view/com/modals/CreateOrEditList.tsx
+++ b/src/view/com/modals/CreateOrEditList.tsx
@@ -1,5 +1,4 @@
 import React, {useState, useCallback, useMemo} from 'react'
-import * as Toast from '../util/Toast'
 import {
   ActivityIndicator,
   KeyboardAvoidingView,
@@ -9,12 +8,12 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
+import {AppBskyGraphDefs} from '@atproto/api'
 import LinearGradient from 'react-native-linear-gradient'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {useStores} from 'state/index'
-import {ListModel} from 'state/models/content/list'
+import * as Toast from '../util/Toast'
 import {s, colors, gradients} from 'lib/styles'
 import {enforceLen} from 'lib/strings/helpers'
 import {compressIfNeeded} from 'lib/media/manip'
@@ -27,6 +26,10 @@ import {cleanError, isNetworkError} from 'lib/strings/errors'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
+import {
+  useListCreateMutation,
+  useListMetadataMutation,
+} from '#/state/queries/list'
 
 const MAX_NAME = 64 // todo
 const MAX_DESCRIPTION = 300 // todo
@@ -40,9 +43,8 @@ export function Component({
 }: {
   purpose?: string
   onSave?: (uri: string) => void
-  list?: ListModel
+  list?: AppBskyGraphDefs.ListView
 }) {
-  const store = useStores()
   const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [error, setError] = useState<string>('')
@@ -50,10 +52,12 @@ export function Component({
   const theme = useTheme()
   const {track} = useAnalytics()
   const {_} = useLingui()
+  const listCreateMutation = useListCreateMutation()
+  const listMetadataMutation = useListMetadataMutation()
 
   const activePurpose = useMemo(() => {
-    if (list?.data?.purpose) {
-      return list.data.purpose
+    if (list?.purpose) {
+      return list.purpose
     }
     if (purpose) {
       return purpose
@@ -64,11 +68,11 @@ export function Component({
   const purposeLabel = isCurateList ? 'User' : 'Moderation'
 
   const [isProcessing, setProcessing] = useState<boolean>(false)
-  const [name, setName] = useState<string>(list?.data?.name || '')
+  const [name, setName] = useState<string>(list?.name || '')
   const [description, setDescription] = useState<string>(
-    list?.data?.description || '',
+    list?.description || '',
   )
-  const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar)
+  const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
   const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
 
   const onPressCancel = useCallback(() => {
@@ -111,7 +115,8 @@ export function Component({
     }
     try {
       if (list) {
-        await list.updateMetadata({
+        await listMetadataMutation.mutateAsync({
+          uri: list.uri,
           name: nameTrimmed,
           description: description.trim(),
           avatar: newAvatar,
@@ -119,7 +124,7 @@ export function Component({
         Toast.show(`${purposeLabel} list updated`)
         onSave?.(list.uri)
       } else {
-        const res = await ListModel.createList(store, {
+        const res = await listCreateMutation.mutateAsync({
           purpose: activePurpose,
           name,
           description,
@@ -145,7 +150,6 @@ export function Component({
     setError,
     error,
     onSave,
-    store,
     closeModal,
     activePurpose,
     isCurateList,
@@ -154,6 +158,8 @@ export function Component({
     description,
     newAvatar,
     list,
+    listMetadataMutation,
+    listCreateMutation,
   ])
 
   return (
diff --git a/src/view/com/modals/ListAddUser.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx
index e59ab90df..d34194c41 100644
--- a/src/view/com/modals/ListAddUser.tsx
+++ b/src/view/com/modals/ListAddRemoveUsers.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useCallback, useState, useMemo} from 'react'
+import React, {useCallback, useState} from 'react'
 import {
   ActivityIndicator,
   Pressable,
@@ -6,17 +6,13 @@ import {
   StyleSheet,
   View,
 } from 'react-native'
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
 import {ScrollView, TextInput} from './util'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
 import {UserAvatar} from '../util/UserAvatar'
 import * as Toast from '../util/Toast'
-import {useStores} from 'state/index'
-import {ListModel} from 'state/models/content/list'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
@@ -29,49 +25,37 @@ import {HITSLOP_20} from '#/lib/constants'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
+import {
+  useDangerousListMembershipsQuery,
+  getMembership,
+  ListMembersip,
+  useListMembershipAddMutation,
+  useListMembershipRemoveMutation,
+} from '#/state/queries/list-memberships'
+import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 
 export const snapPoints = ['90%']
 
-export const Component = observer(function Component({
+export function Component({
   list,
-  onAdd,
+  onChange,
 }: {
-  list: ListModel
-  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
+  list: AppBskyGraphDefs.ListView
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: AppBskyActorDefs.ProfileViewBasic,
+  ) => void
 }) {
   const pal = usePalette('default')
-  const store = useStores()
   const {_} = useLingui()
   const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [query, setQuery] = useState('')
-  const autocompleteView = useMemo<UserAutocompleteModel>(
-    () => new UserAutocompleteModel(store),
-    [store],
-  )
+  const autocomplete = useActorAutocompleteQuery(query)
+  const {data: memberships} = useDangerousListMembershipsQuery()
   const [isKeyboardVisible] = useIsKeyboardVisible()
 
-  // initial setup
-  useEffect(() => {
-    autocompleteView.setup().then(() => {
-      autocompleteView.setPrefix('')
-    })
-    autocompleteView.setActive(true)
-    list.loadAll()
-  }, [autocompleteView, list])
-
-  const onChangeQuery = useCallback(
-    (text: string) => {
-      setQuery(text)
-      autocompleteView.setPrefix(text)
-    },
-    [setQuery, autocompleteView],
-  )
-
-  const onPressCancelSearch = useCallback(
-    () => onChangeQuery(''),
-    [onChangeQuery],
-  )
+  const onPressCancelSearch = useCallback(() => setQuery(''), [setQuery])
 
   return (
     <SafeAreaView
@@ -86,7 +70,7 @@ export const Component = observer(function Component({
             placeholder="Search for users"
             placeholderTextColor={pal.colors.textLight}
             value={query}
-            onChangeText={onChangeQuery}
+            onChangeText={setQuery}
             accessible={true}
             accessibilityLabel={_(msg`Search`)}
             accessibilityHint=""
@@ -116,19 +100,20 @@ export const Component = observer(function Component({
           style={[s.flex1]}
           keyboardDismissMode="none"
           keyboardShouldPersistTaps="always">
-          {autocompleteView.isLoading ? (
+          {autocomplete.isLoading ? (
             <View style={{marginVertical: 20}}>
               <ActivityIndicator />
             </View>
-          ) : autocompleteView.suggestions.length ? (
+          ) : autocomplete.data?.length ? (
             <>
-              {autocompleteView.suggestions.slice(0, 40).map((item, i) => (
+              {autocomplete.data.slice(0, 40).map((item, i) => (
                 <UserResult
                   key={item.did}
                   list={list}
                   profile={item}
+                  memberships={memberships}
                   noBorder={i === 0}
-                  onAdd={onAdd}
+                  onChange={onChange}
                 />
               ))}
             </>
@@ -139,7 +124,7 @@ export const Component = observer(function Component({
                 pal.textLight,
                 {paddingHorizontal: 12, paddingVertical: 16},
               ]}>
-              <Trans>No results found for {autocompleteView.prefix}</Trans>
+              <Trans>No results found for {query}</Trans>
             </Text>
           )}
         </ScrollView>
@@ -162,36 +147,71 @@ export const Component = observer(function Component({
       </View>
     </SafeAreaView>
   )
-})
+}
 
 function UserResult({
   profile,
   list,
+  memberships,
   noBorder,
-  onAdd,
+  onChange,
 }: {
   profile: AppBskyActorDefs.ProfileViewBasic
-  list: ListModel
+  list: AppBskyGraphDefs.ListView
+  memberships: ListMembersip[] | undefined
   noBorder: boolean
-  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: AppBskyActorDefs.ProfileViewBasic,
+  ) => void | undefined
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const [isProcessing, setIsProcessing] = useState(false)
-  const [isAdded, setIsAdded] = useState(list.isMember(profile.did))
+  const membership = React.useMemo(
+    () => getMembership(memberships, list.uri, profile.did),
+    [memberships, list.uri, profile.did],
+  )
+  const listMembershipAddMutation = useListMembershipAddMutation()
+  const listMembershipRemoveMutation = useListMembershipRemoveMutation()
 
-  const onPressAdd = useCallback(async () => {
+  const onToggleMembership = useCallback(async () => {
+    if (typeof membership === 'undefined') {
+      return
+    }
     setIsProcessing(true)
     try {
-      await list.addMember(profile)
-      Toast.show('Added to list')
-      setIsAdded(true)
-      onAdd?.(profile)
+      if (membership === false) {
+        await listMembershipAddMutation.mutateAsync({
+          listUri: list.uri,
+          actorDid: profile.did,
+        })
+        Toast.show(_(msg`Added to list`))
+        onChange?.('add', profile)
+      } else {
+        await listMembershipRemoveMutation.mutateAsync({
+          listUri: list.uri,
+          actorDid: profile.did,
+          membershipUri: membership,
+        })
+        Toast.show(_(msg`Removed from list`))
+        onChange?.('remove', profile)
+      }
     } catch (e) {
       Toast.show(cleanError(e))
     } finally {
       setIsProcessing(false)
     }
-  }, [list, profile, setIsProcessing, setIsAdded, onAdd])
+  }, [
+    _,
+    list,
+    profile,
+    membership,
+    setIsProcessing,
+    onChange,
+    listMembershipAddMutation,
+    listMembershipRemoveMutation,
+  ])
 
   return (
     <View
@@ -233,16 +253,14 @@ function UserResult({
         {!!profile.viewer?.followedBy && <View style={s.flexRow} />}
       </View>
       <View>
-        {isAdded ? (
-          <FontAwesomeIcon icon="check" />
-        ) : isProcessing ? (
+        {isProcessing || typeof membership === 'undefined' ? (
           <ActivityIndicator />
         ) : (
           <Button
             testID={`user-${profile.handle}-addBtn`}
             type="default"
-            label="Add"
-            onPress={onPressAdd}
+            label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
+            onPress={onToggleMembership}
           />
         )}
       </View>
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index c1999c5d6..38c8bc7ba 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -18,7 +18,7 @@ import * as RepostModal from './Repost'
 import * as SelfLabelModal from './SelfLabel'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as UserAddRemoveListsModal from './UserAddRemoveLists'
-import * as ListAddUserModal from './ListAddUser'
+import * as ListAddUserModal from './ListAddRemoveUsers'
 import * as AltImageModal from './AltImage'
 import * as EditImageModal from './AltImage'
 import * as ReportModal from './report/Modal'
@@ -108,7 +108,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'user-add-remove-lists') {
     snapPoints = UserAddRemoveListsModal.snapPoints
     element = <UserAddRemoveListsModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'list-add-user') {
+  } else if (activeModal?.name === 'list-add-remove-users') {
     snapPoints = ListAddUserModal.snapPoints
     element = <ListAddUserModal.Component {...activeModal} />
   } else if (activeModal?.name === 'delete-account') {
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 65c4ee444..28f6c36c9 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -13,7 +13,7 @@ import * as ServerInputModal from './ServerInput'
 import * as ReportModal from './report/Modal'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as UserAddRemoveLists from './UserAddRemoveLists'
-import * as ListAddUserModal from './ListAddUser'
+import * as ListAddUserModal from './ListAddRemoveUsers'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as RepostModal from './Repost'
 import * as SelfLabelModal from './SelfLabel'
@@ -85,7 +85,7 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <CreateOrEditListModal.Component {...modal} />
   } else if (modal.name === 'user-add-remove-lists') {
     element = <UserAddRemoveLists.Component {...modal} />
-  } else if (modal.name === 'list-add-user') {
+  } else if (modal.name === 'list-add-remove-users') {
     element = <ListAddUserModal.Component {...modal} />
   } else if (modal.name === 'crop-image') {
     element = <CropImageModal.Component {...modal} />
diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx
index efcfc43be..73b1bc744 100644
--- a/src/view/com/modals/UserAddRemoveLists.tsx
+++ b/src/view/com/modals/UserAddRemoveLists.tsx
@@ -1,33 +1,32 @@
 import React, {useCallback} from 'react'
-import {observer} from 'mobx-react-lite'
-import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
 import {ListsList} from '../lists/ListsList'
-import {ListsListModel} from 'state/models/lists/lists-list'
-import {ListMembershipModel} from 'state/models/content/list-membership'
 import {Button} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
-import {useStores} from 'state/index'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isAndroid} from 'platform/detection'
-import isEqual from 'lodash.isequal'
-import {logger} from '#/logger'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
+import {
+  useDangerousListMembershipsQuery,
+  getMembership,
+  ListMembersip,
+  useListMembershipAddMutation,
+  useListMembershipRemoveMutation,
+} from '#/state/queries/list-memberships'
+import {cleanError} from '#/lib/strings/errors'
+import {useSession} from '#/state/session'
 
 export const snapPoints = ['fullscreen']
 
-export const Component = observer(function UserAddRemoveListsImpl({
+export function Component({
   subject,
   displayName,
   onAdd,
@@ -38,193 +37,161 @@ export const Component = observer(function UserAddRemoveListsImpl({
   onAdd?: (listUri: string) => void
   onRemove?: (listUri: string) => void
 }) {
-  const store = useStores()
   const {closeModal} = useModalControls()
   const pal = usePalette('default')
   const {_} = useLingui()
-  const palPrimary = usePalette('primary')
-  const palInverted = usePalette('inverted')
-  const [originalSelections, setOriginalSelections] = React.useState<string[]>(
-    [],
-  )
-  const [selected, setSelected] = React.useState<string[]>([])
-  const [membershipsLoaded, setMembershipsLoaded] = React.useState(false)
+  const {data: memberships} = useDangerousListMembershipsQuery()
 
-  const listsList: ListsListModel = React.useMemo(
-    () => new ListsListModel(store, store.me.did),
-    [store],
-  )
-  const memberships: ListMembershipModel = React.useMemo(
-    () => new ListMembershipModel(store, subject),
-    [store, subject],
-  )
-  React.useEffect(() => {
-    listsList.refresh()
-    memberships.fetch().then(
-      () => {
-        const ids = memberships.memberships.map(m => m.value.list)
-        setOriginalSelections(ids)
-        setSelected(ids)
-        setMembershipsLoaded(true)
-      },
-      err => {
-        logger.error('Failed to fetch memberships', {error: err})
-      },
-    )
-  }, [memberships, listsList, store, setSelected, setMembershipsLoaded])
-
-  const onPressCancel = useCallback(() => {
+  const onPressDone = useCallback(() => {
     closeModal()
   }, [closeModal])
 
-  const onPressSave = useCallback(async () => {
-    let changes
-    try {
-      changes = await memberships.updateTo(selected)
-    } catch (err) {
-      logger.error('Failed to update memberships', {error: err})
-      return
-    }
-    Toast.show('Lists updated')
-    for (const uri of changes.added) {
-      onAdd?.(uri)
-    }
-    for (const uri of changes.removed) {
-      onRemove?.(uri)
-    }
-    closeModal()
-  }, [closeModal, selected, memberships, onAdd, onRemove])
-
-  const onToggleSelected = useCallback(
-    (uri: string) => {
-      if (selected.includes(uri)) {
-        setSelected(selected.filter(uri2 => uri2 !== uri))
-      } else {
-        setSelected([...selected, uri])
-      }
-    },
-    [selected, setSelected],
-  )
-
-  const renderItem = useCallback(
-    (list: GraphDefs.ListView, index: number) => {
-      const isSelected = selected.includes(list.uri)
-      return (
-        <Pressable
-          testID={`toggleBtn-${list.name}`}
-          style={[
-            styles.listItem,
-            pal.border,
-            {
-              opacity: membershipsLoaded ? 1 : 0.5,
-              borderTopWidth: index === 0 ? 0 : 1,
-            },
-          ]}
-          accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
-            list.name
-          }`}
-          accessibilityHint=""
-          disabled={!membershipsLoaded}
-          onPress={() => onToggleSelected(list.uri)}>
-          <View style={styles.listItemAvi}>
-            <UserAvatar size={40} avatar={list.avatar} />
-          </View>
-          <View style={styles.listItemContent}>
-            <Text
-              type="lg"
-              style={[s.bold, pal.text]}
-              numberOfLines={1}
-              lineHeight={1.2}>
-              {sanitizeDisplayName(list.name)}
-            </Text>
-            <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-              {list.purpose === 'app.bsky.graph.defs#curatelist' &&
-                'User list '}
-              {list.purpose === 'app.bsky.graph.defs#modlist' &&
-                'Moderation list '}
-              by{' '}
-              {list.creator.did === store.me.did
-                ? 'you'
-                : sanitizeHandle(list.creator.handle, '@')}
-            </Text>
-          </View>
-          {membershipsLoaded && (
-            <View
-              style={
-                isSelected
-                  ? [styles.checkbox, palPrimary.border, palPrimary.view]
-                  : [styles.checkbox, pal.borderDark]
-              }>
-              {isSelected && (
-                <FontAwesomeIcon
-                  icon="check"
-                  style={palInverted.text as FontAwesomeIconStyle}
-                />
-              )}
-            </View>
-          )}
-        </Pressable>
-      )
-    },
-    [
-      pal,
-      palPrimary,
-      palInverted,
-      onToggleSelected,
-      selected,
-      store.me.did,
-      membershipsLoaded,
-    ],
-  )
-
-  // Only show changes button if there are some items on the list to choose from AND user has made changes in selection
-  const canSaveChanges =
-    !listsList.isEmpty && !isEqual(selected, originalSelections)
-
   return (
     <View testID="userAddRemoveListsModal" style={s.hContentRegion}>
       <Text style={[styles.title, pal.text]}>
         <Trans>Update {displayName} in Lists</Trans>
       </Text>
       <ListsList
-        listsList={listsList}
+        filter="all"
         inline
-        renderItem={renderItem}
+        renderItem={(list, index) => (
+          <ListItem
+            index={index}
+            list={list}
+            memberships={memberships}
+            subject={subject}
+            onAdd={onAdd}
+            onRemove={onRemove}
+          />
+        )}
         style={[styles.list, pal.border]}
       />
       <View style={[styles.btns, pal.border]}>
         <Button
-          testID="cancelBtn"
+          testID="doneBtn"
           type="default"
-          onPress={onPressCancel}
+          onPress={onPressDone}
           style={styles.footerBtn}
-          accessibilityLabel={_(msg`Cancel`)}
+          accessibilityLabel={_(msg`Done`)}
           accessibilityHint=""
-          onAccessibilityEscape={onPressCancel}
-          label="Cancel"
+          onAccessibilityEscape={onPressDone}
+          label="Done"
         />
-        {canSaveChanges && (
+      </View>
+    </View>
+  )
+}
+
+function ListItem({
+  index,
+  list,
+  memberships,
+  subject,
+  onAdd,
+  onRemove,
+}: {
+  index: number
+  list: GraphDefs.ListView
+  memberships: ListMembersip[] | undefined
+  subject: string
+  onAdd?: (listUri: string) => void
+  onRemove?: (listUri: string) => void
+}) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const [isProcessing, setIsProcessing] = React.useState(false)
+  const membership = React.useMemo(
+    () => getMembership(memberships, list.uri, subject),
+    [memberships, list.uri, subject],
+  )
+  const listMembershipAddMutation = useListMembershipAddMutation()
+  const listMembershipRemoveMutation = useListMembershipRemoveMutation()
+
+  const onToggleMembership = useCallback(async () => {
+    if (typeof membership === 'undefined') {
+      return
+    }
+    setIsProcessing(true)
+    try {
+      if (membership === false) {
+        await listMembershipAddMutation.mutateAsync({
+          listUri: list.uri,
+          actorDid: subject,
+        })
+        Toast.show(_(msg`Added to list`))
+        onAdd?.(list.uri)
+      } else {
+        await listMembershipRemoveMutation.mutateAsync({
+          listUri: list.uri,
+          actorDid: subject,
+          membershipUri: membership,
+        })
+        Toast.show(_(msg`Removed from list`))
+        onRemove?.(list.uri)
+      }
+    } catch (e) {
+      Toast.show(cleanError(e))
+    } finally {
+      setIsProcessing(false)
+    }
+  }, [
+    _,
+    list,
+    subject,
+    membership,
+    setIsProcessing,
+    onAdd,
+    onRemove,
+    listMembershipAddMutation,
+    listMembershipRemoveMutation,
+  ])
+
+  return (
+    <View
+      testID={`toggleBtn-${list.name}`}
+      style={[
+        styles.listItem,
+        pal.border,
+        {
+          borderTopWidth: index === 0 ? 0 : 1,
+        },
+      ]}>
+      <View style={styles.listItemAvi}>
+        <UserAvatar size={40} avatar={list.avatar} />
+      </View>
+      <View style={styles.listItemContent}>
+        <Text
+          type="lg"
+          style={[s.bold, pal.text]}
+          numberOfLines={1}
+          lineHeight={1.2}>
+          {sanitizeDisplayName(list.name)}
+        </Text>
+        <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+          {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '}
+          {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '}
+          by{' '}
+          {list.creator.did === currentAccount?.did
+            ? 'you'
+            : sanitizeHandle(list.creator.handle, '@')}
+        </Text>
+      </View>
+      <View>
+        {isProcessing || typeof membership === 'undefined' ? (
+          <ActivityIndicator />
+        ) : (
           <Button
-            testID="saveBtn"
-            type="primary"
-            onPress={onPressSave}
-            style={styles.footerBtn}
-            accessibilityLabel={_(msg`Save changes`)}
-            accessibilityHint=""
-            onAccessibilityEscape={onPressSave}
-            label="Save Changes"
+            testID={`user-${subject}-addBtn`}
+            type="default"
+            label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
+            onPress={onToggleMembership}
           />
         )}
-
-        {(listsList.isLoading || !membershipsLoaded) && (
-          <View style={styles.loadingContainer}>
-            <ActivityIndicator />
-          </View>
-        )}
       </View>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx
index a29b0d6c8..906fb5e5b 100644
--- a/src/view/screens/Lists.tsx
+++ b/src/view/screens/Lists.tsx
@@ -3,11 +3,8 @@ import {View} from 'react-native'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {AtUri} from '@atproto/api'
-import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {useStores} from 'state/index'
-import {ListsListModel} from 'state/models/lists/lists-list'
 import {ListsList} from 'view/com/lists/ListsList'
 import {Text} from 'view/com/util/text/Text'
 import {Button} from 'view/com/util/forms/Button'
@@ -21,24 +18,17 @@ import {useModalControls} from '#/state/modals'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'>
 export const ListsScreen = withAuthRequired(
-  observer(function ListsScreenImpl({}: Props) {
+  function ListsScreenImpl({}: Props) {
     const pal = usePalette('default')
-    const store = useStores()
     const setMinimalShellMode = useSetMinimalShellMode()
     const {isMobile} = useWebMediaQueries()
     const navigation = useNavigation<NavigationProp>()
     const {openModal} = useModalControls()
 
-    const listsLists: ListsListModel = React.useMemo(
-      () => new ListsListModel(store, 'my-curatelists'),
-      [store],
-    )
-
     useFocusEffect(
       React.useCallback(() => {
         setMinimalShellMode(false)
-        listsLists.refresh()
-      }, [listsLists, setMinimalShellMode]),
+      }, [setMinimalShellMode]),
     )
 
     const onPressNewList = React.useCallback(() => {
@@ -89,8 +79,8 @@ export const ListsScreen = withAuthRequired(
             </Button>
           </View>
         </SimpleViewHeader>
-        <ListsList listsList={listsLists} style={s.flexGrow1} />
+        <ListsList filter="curate" style={s.flexGrow1} />
       </View>
     )
-  }),
+  },
 )
diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx
index 3892e47c7..098d93cdc 100644
--- a/src/view/screens/ModerationModlists.tsx
+++ b/src/view/screens/ModerationModlists.tsx
@@ -3,11 +3,8 @@ import {View} from 'react-native'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {AtUri} from '@atproto/api'
-import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {useStores} from 'state/index'
-import {ListsListModel} from 'state/models/lists/lists-list'
 import {ListsList} from 'view/com/lists/ListsList'
 import {Text} from 'view/com/util/text/Text'
 import {Button} from 'view/com/util/forms/Button'
@@ -21,24 +18,17 @@ import {useModalControls} from '#/state/modals'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'>
 export const ModerationModlistsScreen = withAuthRequired(
-  observer(function ModerationModlistsScreenImpl({}: Props) {
+  function ModerationModlistsScreenImpl({}: Props) {
     const pal = usePalette('default')
-    const store = useStores()
     const setMinimalShellMode = useSetMinimalShellMode()
     const {isMobile} = useWebMediaQueries()
     const navigation = useNavigation<NavigationProp>()
     const {openModal} = useModalControls()
 
-    const mutelists: ListsListModel = React.useMemo(
-      () => new ListsListModel(store, 'my-modlists'),
-      [store],
-    )
-
     useFocusEffect(
       React.useCallback(() => {
         setMinimalShellMode(false)
-        mutelists.refresh()
-      }, [mutelists, setMinimalShellMode]),
+      }, [setMinimalShellMode]),
     )
 
     const onPressNewList = React.useCallback(() => {
@@ -89,8 +79,8 @@ export const ModerationModlistsScreen = withAuthRequired(
             </Button>
           </View>
         </SimpleViewHeader>
-        <ListsList listsList={mutelists} style={s.flexGrow1} />
+        <ListsList filter="mod" style={s.flexGrow1} />
       </View>
     )
-  }),
+  },
 )
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 03c1703c1..18665f519 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -10,8 +10,7 @@ import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {useNavigation} from '@react-navigation/native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {observer} from 'mobx-react-lite'
-import {RichText as RichTextAPI} from '@atproto/api'
+import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api'
 import {useQueryClient} from '@tanstack/react-query'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
@@ -28,7 +27,6 @@ import * as Toast from 'view/com/util/Toast'
 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
 import {FAB} from 'view/com/util/fab/FAB'
 import {Haptics} from 'lib/haptics'
-import {ListModel} from 'state/models/content/list'
 import {FeedDescriptor} from '#/state/queries/post-feed'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -39,17 +37,24 @@ import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {NavigationProp} from 'lib/routes/types'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {shareUrl} from 'lib/sharing'
-import {resolveName} from 'lib/api'
 import {s} from 'lib/styles'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink, makeListLink} from 'lib/routes/links'
 import {ComposeIcon2} from 'lib/icons'
-import {ListItems} from 'view/com/lists/ListItems'
-import {logger} from '#/logger'
+import {ListMembers} from '#/view/com/lists/ListMembers'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {useModalControls} from '#/state/modals'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {
+  useListQuery,
+  useListMuteMutation,
+  useListBlockMutation,
+  useListDeleteMutation,
+} from '#/state/queries/list'
+import {cleanError} from '#/lib/strings/errors'
+import {useSession} from '#/state/session'
 
 const SECTION_TITLES_CURATE = ['Posts', 'About']
 const SECTION_TITLES_MOD = ['About']
@@ -60,40 +65,32 @@ interface SectionRef {
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
 export const ProfileListScreen = withAuthRequired(
-  observer(function ProfileListScreenImpl(props: Props) {
-    const store = useStores()
-    const {name: handleOrDid} = props.route.params
-    const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>()
-    const [error, setError] = React.useState<string | undefined>()
-
-    React.useEffect(() => {
-      /*
-       * We must resolve the DID of the list owner before we can fetch the list.
-       */
-      async function fetchDid() {
-        try {
-          const did = await resolveName(store, handleOrDid)
-          setListOwnerDid(did)
-        } catch (e) {
-          setError(
-            `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`,
-          )
-        }
-      }
-
-      fetchDid()
-    }, [store, handleOrDid, setListOwnerDid])
+  function ProfileListScreenImpl(props: Props) {
+    const {name: handleOrDid, rkey} = props.route.params
+    const {data: resolvedUri, error: resolveError} = useResolveUriQuery(
+      AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
+    )
+    const {data: list, error: listError} = useListQuery(resolvedUri)
 
-    if (error) {
+    if (resolveError) {
+      return (
+        <CenteredView>
+          <ErrorScreen
+            error={`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`}
+          />
+        </CenteredView>
+      )
+    }
+    if (listError) {
       return (
         <CenteredView>
-          <ErrorScreen error={error} />
+          <ErrorScreen error={cleanError(listError)} />
         </CenteredView>
       )
     }
 
-    return listOwnerDid ? (
-      <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} />
+    return resolvedUri && list ? (
+      <ProfileListScreenLoaded {...props} uri={resolvedUri} list={list} />
     ) : (
       <CenteredView>
         <View style={s.p20}>
@@ -101,192 +98,172 @@ export const ProfileListScreen = withAuthRequired(
         </View>
       </CenteredView>
     )
-  }),
+  },
 )
 
-export const ProfileListScreenInner = observer(
-  function ProfileListScreenInnerImpl({
-    route,
-    listOwnerDid,
-  }: Props & {listOwnerDid: string}) {
-    const store = useStores()
-    const {_} = useLingui()
-    const queryClient = useQueryClient()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {rkey} = route.params
-    const listUri = `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`
-    const feedSectionRef = React.useRef<SectionRef>(null)
-    const aboutSectionRef = React.useRef<SectionRef>(null)
-    const {openModal} = useModalControls()
-
-    const list: ListModel = useMemo(() => {
-      const model = new ListModel(store, listUri)
-      return model
-    }, [store, listUri])
-    useSetTitle(list.data?.name)
-
-    useFocusEffect(
-      useCallback(() => {
-        setMinimalShellMode(false)
-        list.loadMore(true)
-      }, [setMinimalShellMode, list]),
-    )
-
-    const onPressAddUser = useCallback(() => {
-      openModal({
-        name: 'list-add-user',
-        list,
-        onAdd() {
-          if (list.isCuratelist) {
-            queryClient.invalidateQueries({
-              queryKey: FEED_RQKEY(`list|${listUri}`),
-            })
-          }
-        },
-      })
-    }, [openModal, list, queryClient, listUri])
+function ProfileListScreenLoaded({
+  route,
+  uri,
+  list,
+}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) {
+  const store = useStores()
+  const {_} = useLingui()
+  const queryClient = useQueryClient()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {rkey} = route.params
+  const feedSectionRef = React.useRef<SectionRef>(null)
+  const aboutSectionRef = React.useRef<SectionRef>(null)
+  const {openModal} = useModalControls()
+  const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
+
+  useSetTitle(list.name)
+
+  useFocusEffect(
+    useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
 
-    const onCurrentPageSelected = React.useCallback(
-      (index: number) => {
-        if (index === 0) {
-          feedSectionRef.current?.scrollToTop()
-        }
-        if (index === 1) {
-          aboutSectionRef.current?.scrollToTop()
+  const onPressAddUser = useCallback(() => {
+    openModal({
+      name: 'list-add-remove-users',
+      list,
+      onChange() {
+        if (isCurateList) {
+          queryClient.invalidateQueries({
+            // TODO(eric) should construct these strings with a fn too
+            queryKey: FEED_RQKEY(`list|${list.uri}`),
+          })
         }
       },
-      [feedSectionRef],
-    )
+    })
+  }, [openModal, list, isCurateList, queryClient])
 
-    const renderHeader = useCallback(() => {
-      return <Header rkey={rkey} list={list} />
-    }, [rkey, list])
+  const onCurrentPageSelected = React.useCallback(
+    (index: number) => {
+      if (index === 0) {
+        feedSectionRef.current?.scrollToTop()
+      }
+      if (index === 1) {
+        aboutSectionRef.current?.scrollToTop()
+      }
+    },
+    [feedSectionRef],
+  )
 
-    if (list.isCuratelist) {
-      return (
-        <View style={s.hContentRegion}>
-          <PagerWithHeader
-            items={SECTION_TITLES_CURATE}
-            isHeaderReady={list.hasLoaded}
-            renderHeader={renderHeader}
-            onCurrentPageSelected={onCurrentPageSelected}>
-            {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
-              <FeedSection
-                ref={feedSectionRef}
-                feed={`list|${listUri}`}
-                scrollElRef={
-                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
-                }
-                onScroll={onScroll}
-                headerHeight={headerHeight}
-                isScrolledDown={isScrolledDown}
-              />
-            )}
-            {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
-              <AboutSection
-                ref={aboutSectionRef}
-                scrollElRef={
-                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
-                }
-                list={list}
-                descriptionRT={list.descriptionRT}
-                creator={list.data ? list.data.creator : undefined}
-                isCurateList={list.isCuratelist}
-                isOwner={list.isOwner}
-                onPressAddUser={onPressAddUser}
-                onScroll={onScroll}
-                headerHeight={headerHeight}
-                isScrolledDown={isScrolledDown}
-              />
-            )}
-          </PagerWithHeader>
-          <FAB
-            testID="composeFAB"
-            onPress={() => store.shell.openComposer({})}
-            icon={
-              <ComposeIcon2
-                strokeWidth={1.5}
-                size={29}
-                style={{color: 'white'}}
-              />
-            }
-            accessibilityRole="button"
-            accessibilityLabel={_(msg`New post`)}
-            accessibilityHint=""
-          />
-        </View>
-      )
-    }
-    if (list.isModlist) {
-      return (
-        <View style={s.hContentRegion}>
-          <PagerWithHeader
-            items={SECTION_TITLES_MOD}
-            isHeaderReady={list.hasLoaded}
-            renderHeader={renderHeader}>
-            {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
-              <AboutSection
-                list={list}
-                scrollElRef={
-                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
-                }
-                descriptionRT={list.descriptionRT}
-                creator={list.data ? list.data.creator : undefined}
-                isCurateList={list.isCuratelist}
-                isOwner={list.isOwner}
-                onPressAddUser={onPressAddUser}
-                onScroll={onScroll}
-                headerHeight={headerHeight}
-                isScrolledDown={isScrolledDown}
-              />
-            )}
-          </PagerWithHeader>
-          <FAB
-            testID="composeFAB"
-            onPress={() => store.shell.openComposer({})}
-            icon={
-              <ComposeIcon2
-                strokeWidth={1.5}
-                size={29}
-                style={{color: 'white'}}
-              />
-            }
-            accessibilityRole="button"
-            accessibilityLabel={_(msg`New post`)}
-            accessibilityHint=""
-          />
-        </View>
-      )
-    }
+  const renderHeader = useCallback(() => {
+    return <Header rkey={rkey} list={list} />
+  }, [rkey, list])
+
+  if (isCurateList) {
     return (
-      <CenteredView sideBorders style={s.hContentRegion}>
-        <Header rkey={rkey} list={list} />
-        {list.error ? <ErrorScreen error={list.error} /> : null}
-      </CenteredView>
+      <View style={s.hContentRegion}>
+        <PagerWithHeader
+          items={SECTION_TITLES_CURATE}
+          isHeaderReady={true}
+          renderHeader={renderHeader}
+          onCurrentPageSelected={onCurrentPageSelected}>
+          {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+            <FeedSection
+              ref={feedSectionRef}
+              feed={`list|${uri}`}
+              scrollElRef={
+                scrollElRef as React.MutableRefObject<FlatList<any> | null>
+              }
+              onScroll={onScroll}
+              headerHeight={headerHeight}
+              isScrolledDown={isScrolledDown}
+            />
+          )}
+          {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+            <AboutSection
+              ref={aboutSectionRef}
+              scrollElRef={
+                scrollElRef as React.MutableRefObject<FlatList<any> | null>
+              }
+              list={list}
+              onPressAddUser={onPressAddUser}
+              onScroll={onScroll}
+              headerHeight={headerHeight}
+              isScrolledDown={isScrolledDown}
+            />
+          )}
+        </PagerWithHeader>
+        <FAB
+          testID="composeFAB"
+          onPress={() => store.shell.openComposer({})}
+          icon={
+            <ComposeIcon2
+              strokeWidth={1.5}
+              size={29}
+              style={{color: 'white'}}
+            />
+          }
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`New post`)}
+          accessibilityHint=""
+        />
+      </View>
     )
-  },
-)
+  }
+  return (
+    <View style={s.hContentRegion}>
+      <PagerWithHeader
+        items={SECTION_TITLES_MOD}
+        isHeaderReady={true}
+        renderHeader={renderHeader}>
+        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+          <AboutSection
+            list={list}
+            scrollElRef={
+              scrollElRef as React.MutableRefObject<FlatList<any> | null>
+            }
+            onPressAddUser={onPressAddUser}
+            onScroll={onScroll}
+            headerHeight={headerHeight}
+            isScrolledDown={isScrolledDown}
+          />
+        )}
+      </PagerWithHeader>
+      <FAB
+        testID="composeFAB"
+        onPress={() => store.shell.openComposer({})}
+        icon={
+          <ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} />
+        }
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`New post`)}
+        accessibilityHint=""
+      />
+    </View>
+  )
+}
 
-const Header = observer(function HeaderImpl({
-  rkey,
-  list,
-}: {
-  rkey: string
-  list: ListModel
-}) {
+function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
   const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
+  const {currentAccount} = useSession()
   const {openModal, closeModal} = useModalControls()
+  const listMuteMutation = useListMuteMutation()
+  const listBlockMutation = useListBlockMutation()
+  const listDeleteMutation = useListDeleteMutation()
+  const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
+  const isModList = list.purpose === 'app.bsky.graph.defs#modlist'
+  const isPinned = false // TODO
+  const isBlocking = !!list.viewer?.blocked
+  const isMuting = !!list.viewer?.muted
+  const isOwner = list.creator.did === currentAccount?.did
 
   const onTogglePinned = useCallback(async () => {
     Haptics.default()
-    list.togglePin().catch(e => {
-      Toast.show('There was an issue contacting the server')
-      logger.error('Failed to toggle pinned list', {error: e})
-    })
-  }, [list])
+    // TODO
+    // list.togglePin().catch(e => {
+    //   Toast.show('There was an issue contacting the server')
+    //   logger.error('Failed to toggle pinned list', {error: e})
+    // })
+  }, [])
 
   const onSubscribeMute = useCallback(() => {
     openModal({
@@ -297,7 +274,7 @@ const Header = observer(function HeaderImpl({
       confirmBtnText: 'Mute this List',
       async onPressConfirm() {
         try {
-          await list.mute()
+          await listMuteMutation.mutateAsync({uri: list.uri, mute: true})
           Toast.show('List muted')
         } catch {
           Toast.show(
@@ -309,18 +286,18 @@ const Header = observer(function HeaderImpl({
         closeModal()
       },
     })
-  }, [openModal, closeModal, list])
+  }, [openModal, closeModal, list, listMuteMutation])
 
   const onUnsubscribeMute = useCallback(async () => {
     try {
-      await list.unmute()
+      await listMuteMutation.mutateAsync({uri: list.uri, mute: false})
       Toast.show('List unmuted')
     } catch {
       Toast.show(
         'There was an issue. Please check your internet connection and try again.',
       )
     }
-  }, [list])
+  }, [list, listMuteMutation])
 
   const onSubscribeBlock = useCallback(() => {
     openModal({
@@ -331,7 +308,7 @@ const Header = observer(function HeaderImpl({
       confirmBtnText: 'Block this List',
       async onPressConfirm() {
         try {
-          await list.block()
+          await listBlockMutation.mutateAsync({uri: list.uri, block: true})
           Toast.show('List blocked')
         } catch {
           Toast.show(
@@ -343,26 +320,23 @@ const Header = observer(function HeaderImpl({
         closeModal()
       },
     })
-  }, [openModal, closeModal, list])
+  }, [openModal, closeModal, list, listBlockMutation])
 
   const onUnsubscribeBlock = useCallback(async () => {
     try {
-      await list.unblock()
+      await listBlockMutation.mutateAsync({uri: list.uri, block: false})
       Toast.show('List unblocked')
     } catch {
       Toast.show(
         'There was an issue. Please check your internet connection and try again.',
       )
     }
-  }, [list])
+  }, [list, listBlockMutation])
 
   const onPressEdit = useCallback(() => {
     openModal({
       name: 'create-or-edit-list',
       list,
-      onSave() {
-        list.refresh()
-      },
     })
   }, [openModal, list])
 
@@ -372,7 +346,7 @@ const Header = observer(function HeaderImpl({
       title: 'Delete List',
       message: 'Are you sure?',
       async onPressConfirm() {
-        await list.delete()
+        await listDeleteMutation.mutateAsync({uri: list.uri})
         Toast.show('List deleted')
         if (navigation.canGoBack()) {
           navigation.goBack()
@@ -381,26 +355,22 @@ const Header = observer(function HeaderImpl({
         }
       },
     })
-  }, [openModal, list, navigation])
+  }, [openModal, list, listDeleteMutation, navigation])
 
   const onPressReport = useCallback(() => {
-    if (!list.data) return
     openModal({
       name: 'report',
       uri: list.uri,
-      cid: list.data.cid,
+      cid: list.cid,
     })
   }, [openModal, list])
 
   const onPressShare = useCallback(() => {
-    const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`)
+    const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`)
     shareUrl(url)
-  }, [list.creatorDid, rkey])
+  }, [list, rkey])
 
   const dropdownItems: DropdownItem[] = useMemo(() => {
-    if (!list.hasLoaded) {
-      return []
-    }
     let items: DropdownItem[] = [
       {
         testID: 'listHeaderDropdownShareBtn',
@@ -415,7 +385,7 @@ const Header = observer(function HeaderImpl({
         },
       },
     ]
-    if (list.isOwner) {
+    if (isOwner) {
       items.push({label: 'separator'})
       items.push({
         testID: 'listHeaderDropdownEditBtn',
@@ -457,14 +427,7 @@ const Header = observer(function HeaderImpl({
       })
     }
     return items
-  }, [
-    list.hasLoaded,
-    list.isOwner,
-    onPressShare,
-    onPressEdit,
-    onPressDelete,
-    onPressReport,
-  ])
+  }, [isOwner, onPressShare, onPressEdit, onPressDelete, onPressReport])
 
   const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
     return [
@@ -497,32 +460,28 @@ const Header = observer(function HeaderImpl({
 
   return (
     <ProfileSubpageHeader
-      isLoading={!list.hasLoaded}
-      href={makeListLink(
-        list.data?.creator.handle || list.data?.creator.did || '',
-        rkey,
-      )}
-      title={list.data?.name || 'User list'}
-      avatar={list.data?.avatar}
-      isOwner={list.isOwner}
-      creator={list.data?.creator}
+      href={makeListLink(list.creator.handle || list.creator.did || '', rkey)}
+      title={list.name}
+      avatar={list.avatar}
+      isOwner={list.creator.did === currentAccount?.did}
+      creator={list.creator}
       avatarType="list">
-      {list.isCuratelist || list.isPinned ? (
+      {isCurateList || isPinned ? (
         <Button
           testID={list.isPinned ? 'unpinBtn' : 'pinBtn'}
           type={list.isPinned ? 'default' : 'inverted'}
           label={list.isPinned ? 'Unpin' : 'Pin to home'}
           onPress={onTogglePinned}
         />
-      ) : list.isModlist ? (
-        list.isBlocking ? (
+      ) : isModList ? (
+        isBlocking ? (
           <Button
             testID="unblockBtn"
             type="default"
             label="Unblock"
             onPress={onUnsubscribeBlock}
           />
-        ) : list.isMuting ? (
+        ) : isMuting ? (
           <Button
             testID="unmuteBtn"
             type="default"
@@ -554,7 +513,7 @@ const Header = observer(function HeaderImpl({
       </NativeDropdown>
     </ProfileSubpageHeader>
   )
-})
+}
 
 interface FeedSectionProps {
   feed: FeedDescriptor
@@ -610,11 +569,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
 )
 
 interface AboutSectionProps {
-  list: ListModel
-  descriptionRT: RichTextAPI | null
-  creator: {did: string; handle: string} | undefined
-  isCurateList: boolean | undefined
-  isOwner: boolean | undefined
+  list: AppBskyGraphDefs.ListView
   onPressAddUser: () => void
   onScroll: OnScrollHandler
   headerHeight: number
@@ -623,23 +578,26 @@ interface AboutSectionProps {
 }
 const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
   function AboutSectionImpl(
-    {
-      list,
-      descriptionRT,
-      creator,
-      isCurateList,
-      isOwner,
-      onPressAddUser,
-      onScroll,
-      headerHeight,
-      isScrolledDown,
-      scrollElRef,
-    },
+    {list, onPressAddUser, onScroll, headerHeight, isScrolledDown, scrollElRef},
     ref,
   ) {
     const pal = usePalette('default')
     const {_} = useLingui()
     const {isMobile} = useWebMediaQueries()
+    const {currentAccount} = useSession()
+    const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
+    const isOwner = list.creator.did === currentAccount?.did
+
+    const descriptionRT = useMemo(
+      () =>
+        list.description
+          ? new RichTextAPI({
+              text: list.description,
+              facets: list.descriptionFacets,
+            })
+          : undefined,
+      [list],
+    )
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -headerHeight})
@@ -650,9 +608,6 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
     }))
 
     const renderHeader = React.useCallback(() => {
-      if (!list.data) {
-        return <View />
-      }
       return (
         <View>
           <View
@@ -685,8 +640,8 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
                 'you'
               ) : (
                 <TextLink
-                  text={sanitizeHandle(creator?.handle || '', '@')}
-                  href={creator ? makeProfileLink(creator) : ''}
+                  text={sanitizeHandle(list.creator.handle || '', '@')}
+                  href={makeProfileLink(list.creator)}
                   style={pal.textLight}
                 />
               )}
@@ -728,10 +683,9 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
       )
     }, [
       pal,
-      list.data,
+      list,
       isMobile,
       descriptionRT,
-      creator,
       isCurateList,
       isOwner,
       onPressAddUser,
@@ -750,12 +704,12 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
 
     return (
       <View>
-        <ListItems
+        <ListMembers
           testID="listItems"
+          list={list.uri}
           scrollElRef={scrollElRef}
           renderHeader={renderHeader}
           renderEmptyState={renderEmptyState}
-          list={list}
           headerOffset={headerHeight}
           onScroll={onScroll}
           scrollEventThrottle={1}