Skip to content

🏝️ TanStack Query DevTools for Expo/React Native! 🚀 #8846

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
4 changes: 1 addition & 3 deletions docs/framework/react/devtools.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ Wave your hands in the air and shout hooray because React Query comes with dedic

When you begin your React Query journey, you'll want these devtools by your side. They help visualize all the inner workings of React Query and will likely save you hours of debugging if you find yourself in a pinch!

> Please note that for now, the devtools **do not support React Native**. If you would like to help us make the devtools platform-agnostic, please let us know!

> Exciting News: We now have a separate package for React Native React Query DevTools! This new addition brings native support, allowing you to integrate DevTools directly into your React Native projects. Check it out and contribute here: [react-native-react-query-devtools](https://github.com/LovesWorking/react-native-react-query-devtools)
> For React Native users: We now have an Expo plugin for React Query DevTools! This brings native support, allowing you to integrate DevTools directly into your React Native projects. Check it out here: [tanstack-query-dev-tools-expo-plugin](https://github.com/LovesWorking/tanstack-query-dev-tools-expo-plugin)

> An external tool is also available that enables the use of React Query DevTools via an external dashboard. Find out more and contribute on [react-query-external-sync](https://github.com/LovesWorking/react-query-external-sync)

Expand Down
15 changes: 10 additions & 5 deletions docs/framework/react/react-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ id: react-native
title: React Native
---

React Query is designed to work out of the box with React Native, with the exception of the devtools, which are only supported with React DOM at this time.
React Query is designed to work out of the box with React Native.

There is a 3rd party [Expo](https://docs.expo.dev/) plugin which you can try: https://github.com/expo/dev-plugins/tree/main/packages/react-query
## DevTools Support

There is a 3rd party [Flipper](https://fbflipper.com/docs/getting-started/react-native/) plugin which you can try: https://github.com/bgaleotti/react-query-native-devtools
There are several options available for React Native DevTools integration:

There is a 3rd party [Reactotron](https://github.com/infinitered/reactotron/) plugin which you can try: https://github.com/hsndmr/reactotron-react-query
1. **Expo Plugin**: A 3rd party plugin for Expo. This is the recommended way to use React Query DevTools in React Native:
https://github.com/LovesWorking/tanstack-query-dev-tools-expo-plugin

If you would like to help us make the built-in devtools platform agnostic, please let us know!
2. **Flipper Plugin**: A 3rd party plugin for Flipper users:
https://github.com/bgaleotti/react-query-native-devtools

3. **Reactotron Plugin**: A 3rd party plugin for Reactotron users:
https://github.com/hsndmr/reactotron-react-query

## Online status management

Expand Down
42 changes: 42 additions & 0 deletions packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,38 @@ interface ContinueAction {
type: 'continue'
}

export interface RefetchActionEvent {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think these should be here. The actions listened on the NotifyEvents are events that are dispatched from our query reducer. Consumers can listen to those to observe updates that happen in the cache. When the devtools trigger an update that will result in an internal state change, those changes will automatically be propagated.

Can you explain why those were added?

Copy link
Contributor Author

@LovesWorking LovesWorking Mar 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any other ideas to capture action events that's reliable? I added these, so I know exactly what action is pressed to forward the action to mobile. it just seemed like the easiest approach to subscribe to query events.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, I don’t understand what you’re trying to do. Why do you need to capture an event that happens in the devtools? Capture it where?

Assume I understand nothing about react native and expo (which is 99% true) and try to break down for me what you’re doing. If you want to listen to events from the devtools, doing that by dispatching an event on the queryCache and then listening to that is pretty likely not the right approach

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My plugin includes a web view that runs the React Query DevTools. The challenge I’m facing is linking DevTools actions to another client—in this case, mobile. I need to know when actions like refetch, invalidate, or set online/offline are triggered inside the DevTools UI.

Right now, the web view listens for those actions and sends a message to the mobile client to trigger the same action there. This works for all DevTools actions and online state changes.

If there's a simpler way to detect which action was pressed—along with the associated query and action type—I’d be happy to update the approach.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so should we make the devtools emit those events and you’d directly listen to those?

Copy link
Contributor Author

@LovesWorking LovesWorking Mar 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// 🌐 Web Client (DevTools in WebView) — Listening for Query Actions
// Subscribe to query changes
`const querySubscription = queryClient.getQueryCache().subscribe((event) => {
  switch (event.type) {
    case "updated":
      switch (event.action.type as QueryActions) {
        case "ACTION-REFETCH":
        case "ACTION-INVALIDATE":
        case "ACTION-TRIGGER-ERROR":
        case "ACTION-RESTORE-ERROR":
        case "ACTION-RESET":
        case "ACTION-REMOVE":
        case "ACTION-TRIGGER-LOADING":
        case "ACTION-RESTORE-LOADING":
          client.sendMessage("query-action", {
            queryHash: event.query.queryHash,
            queryKey: event.query.queryKey,
            action: event.action.type as QueryActions,
            targetDevice: selectedDeviceRef.current,
          } as QueryActionMessage);
          break;

        case "success":
          // @ts-ignore
          if (event.action.manual) {
            client.sendMessage("query-action", {
              queryHash: event.query.queryHash,
              queryKey: event.query.queryKey,
              data: event.query.state.data,
              action: "ACTION-DATA-UPDATE",
              targetDevice: selectedDeviceRef.current,
            } as QueryActionMessage);
          }
          break;
      }
  }
});


// 📱 Mobile Client — Handling Incoming Actions from Web DevTools

// Query Actions handler - Update query data, trigger errors, etc.
`const queryActionSubscription = client.addMessageListener(
  "query-action",
  (message: QueryActionMessage) => {
    const { queryHash, queryKey, data, action, targetDevice } = message;

    if (!shouldProcessMessage(targetDevice, Device.deviceName || "")) {
      return;
    }

    const activeQuery = queryClient.getQueryCache().get(queryHash);
    if (!activeQuery) {
      console.warn(`Query with hash ${queryHash} not found`);
      return;
    }

    switch (action) {
      case "ACTION-DATA-UPDATE": {
        queryClient.setQueryData(queryKey, data, {
          updatedAt: Date.now(),
        });
        break;
      }

      case "ACTION-TRIGGER-ERROR": {
        const error = new Error("Unknown error from devtools");

        const __previousQueryOptions = activeQuery.options;
        activeQuery.setState({
          status: "error",
          error,
          fetchMeta: {
            ...activeQuery.state.fetchMeta,
            // @ts-ignore
            __previousQueryOptions,
          },
        });
        break;
      }

      case "ACTION-RESTORE-ERROR": {
        queryClient.resetQueries(activeQuery);
        break;
      }

      case "ACTION-TRIGGER-LOADING": {
        const __previousQueryOptions = activeQuery.options;

        // Trigger a fetch that never resolves to simulate loading
        activeQuery.fetch({
          ...__previousQueryOptions,
          queryFn: () => new Promise(() => {}),
          gcTime: -1,
        });

        activeQuery.setState({
          data: undefined,
          status: "pending",
          fetchMeta: {
            ...activeQuery.state.fetchMeta,
            // @ts-ignore
            __previousQueryOptions,
          },
        });
        break;
      }

      case "ACTION-RESTORE-LOADING": {
        const previousState = activeQuery.state;
        const previousOptions = activeQuery.state.fetchMeta
          ? (activeQuery.state.fetchMeta as any).__previousQueryOptions
          : null;

        activeQuery.cancel({ silent: true });
        activeQuery.setState({
          ...previousState,
          fetchStatus: "idle",
          fetchMeta: null,
        });

        if (previousOptions) {
          activeQuery.fetch(previousOptions);
        }
        break;
      }

      case "ACTION-RESET": {
        queryClient.resetQueries(activeQuery);
        break;
      }

      case "ACTION-REMOVE": {
        queryClient.removeQueries(activeQuery);
        break;
      }

      case "ACTION-REFETCH": {
        activeQuery.fetch().catch(() => {});
        break;
      }

      case "ACTION-INVALIDATE": {
        queryClient.invalidateQueries(activeQuery);
        break;
      }

      case "ACTION-ONLINE-MANAGER-ONLINE": {
        onlineManager.setOnline(true);
        break;
      }

      case "ACTION-ONLINE-MANAGER-OFFLINE": {
        onlineManager.setOnline(false);
        break;
      }
    }
  }
);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so should we make the devtools emit those events and you’d directly listen to those?

Yes!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay, just to be on the same page: this would mean no changes to the query-core, right? because those messages wouldn’t go through the query cache...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay great 👍

type: 'ACTION-REFETCH'
}

export interface InvalidateActionEvent {
type: 'ACTION-INVALIDATE'
}

export interface TriggerErrorActionEvent {
type: 'ACTION-TRIGGER-ERROR'
}

export interface RestoreErrorActionEvent {
type: 'ACTION-RESTORE-ERROR'
}

export interface ResetActionEvent {
type: 'ACTION-RESET'
}

export interface RemoveActionEvent {
type: 'ACTION-REMOVE'
}

export interface TriggerLoadingActionEvent {
type: 'ACTION-TRIGGER-LOADING'
}

export interface RestoreLoadingActionEvent {
type: 'ACTION-RESTORE-LOADING'
}

interface SetStateAction<TData, TError> {
type: 'setState'
state: Partial<QueryState<TData, TError>>
Expand All @@ -148,6 +180,14 @@ export type Action<TData, TError> =
| PauseAction
| SetStateAction<TData, TError>
| SuccessAction<TData>
| TriggerErrorActionEvent
| RestoreErrorActionEvent
| RefetchActionEvent
| RestoreLoadingActionEvent
| RemoveActionEvent
| TriggerLoadingActionEvent
| ResetActionEvent
| InvalidateActionEvent
Comment on lines +183 to +190
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even though we’re not doing anything with these action in the query-core, the type is still widened, which means users will likely start to listen to these events even though they cannot be received except from the devtools.

okay, just to be on the same page: this would mean no changes to the query-core, right?

I don’t think we’re talking about the same things till, because no changes to the query-core means the package isn’t touched, but that’s not the case right now.

so should we make the devtools emit those events and you’d directly listen to those?

the devtools now emit the events, but they emit them to the QueryCache still. The only reason they do this is, I think, because the only thing you can subscribe to is the QueryCache.

I think what I wanted to get to is that you somehow subscribe to the devtools themselves and listen to their events in addition to subscribing to the cache; I don’t really want the events to be the same thing and go through the cache!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that makes much more sense. That was my original plan as well... I’m not sure why I changed it. Probably too much Red Bull these past few weeks 😅. I’ll work on that, thank you!!


export interface SetStateOptions {
meta?: any
Expand Down Expand Up @@ -615,6 +655,8 @@ export class Query<
...state,
...action.state,
}
default:
return state
}
}

Expand Down
82 changes: 65 additions & 17 deletions packages/query-devtools/src/Devtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import type {
Query,
QueryCache,
QueryCacheNotifyEvent,
QueryClient,
QueryState,
} from '@tanstack/query-core'
import type { StorageObject, StorageSetter } from '@solid-primitives/storage'
Expand Down Expand Up @@ -120,6 +121,18 @@ export const Devtools: Component<DevtoolsPanelProps> = (props) => {
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const onlineManager = createMemo(
() => useQueryDevtoolsContext().onlineManager,
)
onMount(() => {
const unsubscribe = onlineManager().subscribe((online) => {
setOffline(!online)
})

onCleanup(() => {
unsubscribe()
})
})

const pip = usePiPWindow()

Expand Down Expand Up @@ -939,13 +952,7 @@ export const ContentView: Component<ContentViewProps> = (props) => {
</button>
<button
onClick={() => {
if (offline()) {
onlineManager().setOnline(true)
setOffline(false)
} else {
onlineManager().setOnline(false)
setOffline(true)
}
onlineManager().setOnline(!onlineManager().isOnline())
}}
class={cx(
styles().actionsBtn,
Expand Down Expand Up @@ -1768,28 +1775,34 @@ const QueryDetails = () => {
const color = createMemo(() => getQueryStatusColorByLabel(statusLabel()))

const handleRefetch = () => {
notifyDevtools(queryClient, activeQuery(), 'REFETCH')
const promise = activeQuery()?.fetch()
promise?.catch(() => {})
}

const triggerError = (errorType?: DevtoolsErrorType) => {
const activeQueryVal = activeQuery()
if (!activeQueryVal) return
notifyDevtools(queryClient, activeQueryVal, 'TRIGGER_ERROR')
const error =
errorType?.initializer(activeQuery()!) ??
errorType?.initializer(activeQueryVal) ??
new Error('Unknown error from devtools')

const __previousQueryOptions = activeQuery()!.options
const __previousQueryOptions = activeQueryVal.options

activeQuery()!.setState({
activeQueryVal.setState({
status: 'error',
error,
fetchMeta: {
...activeQuery()!.state.fetchMeta,
...activeQueryVal.state.fetchMeta,
// @ts-ignore This does exist
__previousQueryOptions,
} as any,
} as QueryState<unknown, Error>)
},
})
}

const restoreQueryAfterLoadingOrError = () => {
notifyDevtools(queryClient, activeQuery(), 'RESTORE_LOADING')
const activeQueryVal = activeQuery()!
const previousState = activeQueryVal.state
const previousOptions = activeQueryVal.state.fetchMeta
Expand Down Expand Up @@ -1899,7 +1912,10 @@ const QueryDetails = () => {
'tsqd-query-details-actions-btn',
'tsqd-query-details-action-invalidate',
)}
onClick={() => queryClient.invalidateQueries(activeQuery())}
onClick={() => {
notifyDevtools(queryClient, activeQuery(), 'INVALIDATE')
queryClient.invalidateQueries(activeQuery())
}}
disabled={queryStatus() === 'pending'}
>
<span
Expand All @@ -1917,7 +1933,10 @@ const QueryDetails = () => {
'tsqd-query-details-actions-btn',
'tsqd-query-details-action-reset',
)}
onClick={() => queryClient.resetQueries(activeQuery())}
onClick={() => {
notifyDevtools(queryClient, activeQuery(), 'RESET')
queryClient.resetQueries(activeQuery())
}}
disabled={queryStatus() === 'pending'}
>
<span
Expand All @@ -1936,6 +1955,7 @@ const QueryDetails = () => {
'tsqd-query-details-action-remove',
)}
onClick={() => {
notifyDevtools(queryClient, activeQuery(), 'REMOVE')
queryClient.removeQueries(activeQuery())
setSelectedQueryHash(null)
}}
Expand All @@ -1962,6 +1982,7 @@ const QueryDetails = () => {
setRestoringLoading(true)
restoreQueryAfterLoadingOrError()
} else {
notifyDevtools(queryClient, activeQuery(), 'TRIGGER_LOADING')
const activeQueryVal = activeQuery()
if (!activeQueryVal) return
const __previousQueryOptions = activeQueryVal.options
Expand All @@ -1981,8 +2002,8 @@ const QueryDetails = () => {
fetchMeta: {
...activeQueryVal.state.fetchMeta,
__previousQueryOptions,
} as any,
} as QueryState<unknown, Error>)
},
} as unknown as QueryState<unknown, Error>)
}
}}
>
Expand All @@ -2006,6 +2027,7 @@ const QueryDetails = () => {
if (!activeQuery()!.state.error) {
triggerError()
} else {
notifyDevtools(queryClient, activeQuery(), 'RESTORE_ERROR')
queryClient.resetQueries(activeQuery())
}
}}
Expand Down Expand Up @@ -2438,6 +2460,32 @@ const createSubscribeToMutationCacheBatcher = <T,>(
return value
}

const DevtoolsActions = {
REFETCH: 'ACTION-REFETCH',
INVALIDATE: 'ACTION-INVALIDATE',
RESET: 'ACTION-RESET',
REMOVE: 'ACTION-REMOVE',
TRIGGER_ERROR: 'ACTION-TRIGGER-ERROR',
RESTORE_ERROR: 'ACTION-RESTORE-ERROR',
TRIGGER_LOADING: 'ACTION-TRIGGER-LOADING',
RESTORE_LOADING: 'ACTION-RESTORE-LOADING',
} as const

const notifyDevtools = (
queryClient: QueryClient,
query: Query | undefined,
actionType: keyof typeof DevtoolsActions,
) => {
if (!queryClient || !query) return
queryClient.getQueryCache().notify({
query,
type: 'updated',
action: {
type: DevtoolsActions[actionType],
},
})
}

const stylesFactory = (
theme: 'light' | 'dark',
css: (typeof goober)['css'],
Expand Down
Loading