| |

React 65 ⚛️ Mutations and Cache Invalidation with useMutation

⏱️ Estimated read: 15 min | |

A query reads data. A mutation writes it. The useMutation hook is the entry point for the create, update, and delete operations, and it is the counterpart of the useQuery hook. It takes a mutation function, it exposes the state of the operation, and it provides the callbacks for the side effects. The mutation does not touch the cache by default, and the cache invalidation is the developer’s responsibility. The useQueryClient hook provides the methods for the invalidation, the direct cache write, and the optimistic update. Getting the invalidation right is what keeps the UI consistent after a write.

Key point: A mutation does not automatically refetch the affected queries. The mutation function writes the data on the server, and the cache is unchanged. The invalidateQueries call marks the affected queries as stale, and the active queries refetch in the background. The invalidation is the bridge between the write and the read.


Why useMutation exists

The write problem. A query reads data, and the result is cached. A mutation writes data, and the cache is now out of date. The useMutation hook provides the state of the write and the callbacks for the side effects, and the invalidation is the mechanism that refreshes the cache.

The state problem. A mutation has three states: idle, pending, and settled. The isPending, isError, and isSuccess flags track them. The component renders the spinner, the error, or the success message based on the flags.

The lifecycle problem. A mutation has a lifecycle: onMutate before the request, onSuccess on the success, onError on the failure, and onSettled after both. The callbacks are the hooks for the side effects, and they are where the optimistic update and the invalidation live.

The optimistic problem. A mutation that changes the UI should show the result immediately, before the server confirms. The onMutate callback saves the current cache, writes the optimistic value, and the onError callback rolls back if the mutation fails. The onSettled callback invalidates the cache to reconcile.

The variable problem. The mutation function takes variables. The variables are passed to the mutate call, and the callbacks receive them. The variables are the input to the write.

The deduplication problem. A mutation that is called twice in the same tick fires two requests. The useMutation hook provides the mutate and the mutateAsync functions, and the mutateAsync is the promise-based form for the sequential operations.

The error problem. A mutation that fails must be handled. The onError callback receives the error, and the component renders the message. The reset method clears the state for the retry.


a. The basic mutation

The useMutation hook takes a mutation function and returns the mutation object with the mutate function and the state.

The basic usage

import { useMutation } from '@tanstack/react-query';

function AddTodo() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      });
    },
  });

  return (
    <button onClick={() => mutation.mutate({ title: 'New todo' })}>
      Add todo
    </button>
  );
}

The mutationFn is the write function. The mutation.mutate triggers the write with the variables.

The async mutation function

The mutation function is async and throws on error.

const mutation = useMutation({
  mutationFn: async (newTodo) => {
    const response = await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(newTodo),
    });
    if (!response.ok) throw new Error('Failed to add todo');
    return response.json();
  },
});

The response.ok check is required because the fetch API does not throw on HTTP errors.

The state flags

The mutation object exposes the state flags.

const { isPending, isError, isSuccess, error, data } = useMutation({ ... });
FlagMeaning
isPendingThe mutation is in progress
isErrorThe mutation failed
isSuccessThe mutation succeeded
errorThe error object
dataThe resolved value

The mutate versus mutateAsync

The mutate function is the fire-and-forget form. The mutateAsync function returns a promise.

// mutate: no promise
mutation.mutate({ title: 'New todo' });

// mutateAsync: returns a promise
const result = await mutation.mutateAsync({ title: 'New todo' });

The mutateAsync is the form for the sequential operations, and it requires a try/catch for the error handling.

The variables

The variables are passed to the mutate call and to the callbacks.

mutation.mutate({ title: 'New todo' });

useMutation({
  mutationFn: (newTodo) => { ... },
  onSuccess: (data, variables, context) => {
    console.log('Added:', variables.title);
  },
});

The variables is the second argument of the onSuccess callback.

The reset method

The reset method clears the mutation state.

const { reset } = useMutation({ ... });
<button onClick={reset}>Reset</button>

The method is used to clear the error or the success state before the next attempt.


b. The cache invalidation

The mutation does not touch the cache. The invalidateQueries call marks the affected queries as stale, and the active queries refetch.

The basic invalidation

import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return <button onClick={() => mutation.mutate({ title: 'New' })}>Add</button>;
}

After the mutation succeeds, the ['todos'] queries are invalidated. The active ones refetch, and the UI updates.

The prefix invalidation

The prefix matching invalidates every query whose key starts with the given prefix.

queryClient.invalidateQueries({ queryKey: ['todos'] });

The call invalidates ['todos'], ['todos', 1], and ['todos', 1, 'comments']. The hierarchical key design is the reason the invalidation is simple.

The multiple invalidations

The onSuccess callback can invalidate several queries.

onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['todos'] });
  queryClient.invalidateQueries({ queryKey: ['stats'] });
},

The call marks both the list and the stats as stale.

The targeted invalidation

The queryKey with the exact key invalidates only the specific query.

queryClient.invalidateQueries({ queryKey: ['todo', todoId] });

The call invalidates the specific todo, not the whole list.

The refetchType option

The refetchType option controls which queries are refetched.

queryClient.invalidateQueries({
  queryKey: ['todos'],
  refetchType: 'active',
});
ValueMeaning
'active'Refetch the active queries
'inactive'Refetch the inactive queries
'all'Refetch both
'none'Mark stale, do not refetch

The 'none' value is used when the next mount should refetch, but the current one should not.

The setQueryData method

The setQueryData method writes directly to the cache without a refetch.

onSuccess: (newTodo) => {
  queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
},

The method is the manual cache update. It is faster than the invalidation because it does not require a network request.

The setQueryData versus the invalidation

AspectsetQueryDatainvalidateQueries
NetworkNoYes (refetch)
SpeedInstantDepends on the server
ConsistencyThe client writes the valueThe server is the source
Use caseThe known resultThe server-computed result

The setQueryData is the right choice when the mutation returns the updated value. The invalidateQueries is the right choice when the server computes the value.


c. The optimistic update

The optimistic update shows the result before the server confirms. The onMutate callback saves the current cache and writes the optimistic value, and the onError callback rolls back.

The basic optimistic update

const mutation = useMutation({
  mutationFn: addTodo,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    const previousTodos = queryClient.getQueryData(['todos']);

    queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);

    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

The onMutate saves the previous value in the context and writes the optimistic value. The onError restores the previous value. The onSettled invalidates the cache to reconcile with the server.

The cancelQueries

The cancelQueries call cancels the in-flight refetches before the optimistic update. Without it, a refetch that completes after the optimistic write overwrites the optimistic value.

await queryClient.cancelQueries({ queryKey: ['todos'] });

The context

The onMutate returns the context, and the onError and onSettled receive it.

onMutate: async (newTodo) => {
  return { previousTodos };
},
onError: (err, newTodo, context) => {
  queryClient.setQueryData(['todos'], context.previousTodos);
},

The context is the rollback data.

The rollback

The onError callback restores the previous value.

onError: (err, newTodo, context) => {
  queryClient.setQueryData(['todos'], context.previousTodos);
},

The rollback is the reason the onMutate saves the previous value.

The reconciliation

The onSettled callback invalidates the cache after both the success and the error.

onSettled: () => {
  queryClient.invalidateQueries({ queryKey: ['todos'] });
},

The invalidation ensures that the cache matches the server after the optimistic update.

The optimistic update with a temporary ID

The optimistic item needs a temporary ID.

onMutate: async (newTodo) => {
  const optimisticTodo = { ...newTodo, id: `temp-${Date.now()}` };
  queryClient.setQueryData(['todos'], (old) => [...old, optimisticTodo]);
  return { previousTodos };
},

The temporary ID is replaced by the server ID when the mutation succeeds and the cache is invalidated.

The optimistic update table

CallbackPurpose
onMutateCancel, save, write the optimistic value
onErrorRestore the previous value
onSettledInvalidate the cache

The optimistic update anti-patterns

Anti-patternProblem
No rollbackThe optimistic value remains on error
No cancelThe in-flight refetch overwrites
No invalidationThe cache does not match the server
No temporary IDThe list key is duplicated
No error handlerThe user does not see the failure

Complete Example Session

// ============================================
// PART 1: THE BASIC MUTATION
// ============================================

import { useMutation } from '@tanstack/react-query';

function AddTodo() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      });
    },
  });

  return (
    <button onClick={() => mutation.mutate({ title: 'New todo' })}>
      Add todo
    </button>
  );
}

// ============================================
// PART 2: THE STATE FLAGS
// ============================================

function AddTodoWithState() {
  const { mutate, isPending, isError, isSuccess, error } = useMutation({
    mutationFn: addTodo,
  });

  return (
    <div>
      <button onClick={() => mutate({ title: 'New' })} disabled={isPending}>
        {isPending ? 'Adding...' : 'Add'}
      </button>
      {isError && <p>Error: {error.message}</p>}
      {isSuccess && <p>Added!</p>}
    </div>
  );
}

// ============================================
// PART 3: THE CACHE INVALIDATION
// ============================================

import { useQueryClient } from '@tanstack/react-query';

function AddTodoWithInvalidation() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return <button onClick={() => mutation.mutate({ title: 'New' })}>Add</button>;
}

// ============================================
// PART 4: THE SETQUERYDATA
// ============================================

function AddTodoWithSetData() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: addTodo,
    onSuccess: (newTodo) => {
      queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
    },
  });

  return <button onClick={() => mutation.mutate({ title: 'New' })}>Add</button>;
}

// ============================================
// PART 5: THE OPTIMISTIC UPDATE
// ============================================

function OptimisticAddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: addTodo,
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      const previousTodos = queryClient.getQueryData(['todos']);

      queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);

      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return <button onClick={() => mutation.mutate({ title: 'New' })}>Add</button>;
}

// ============================================
// PART 6: THE TEMPORARY ID
// ============================================

const mutation = useMutation({
  mutationFn: addTodo,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previousTodos = queryClient.getQueryData(['todos']);
    const optimisticTodo = { ...newTodo, id: `temp-${Date.now()}` };
    queryClient.setQueryData(['todos'], (old) => [...old, optimisticTodo]);
    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

// ============================================
// PART 7: THE EDIT MUTATION
// ============================================

function EditTodo({ todo }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (updates) => updateTodo(todo.id, updates),
    onSuccess: (updated) => {
      queryClient.setQueryData(['todo', todo.id], updated);
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return (
    <button onClick={() => mutation.mutate({ title: 'Updated' })}>
      Edit
    </button>
  );
}

// ============================================
// PART 8: THE DELETE MUTATION
// ============================================

function DeleteTodo({ todoId }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: () => deleteTodo(todoId),
    onSuccess: () => {
      queryClient.removeQueries({ queryKey: ['todo', todoId] });
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return <button onClick={() => mutation.mutate()}>Delete</button>;
}

// ============================================
// PART 9: THE SEQUENTIAL MUTATIONS
// ============================================

async function handleSubmit() {
  try {
    const todo = await createTodoMutation.mutateAsync({ title: 'New' });
    await assignTodoMutation.mutateAsync({ todoId: todo.id, userId: 42 });
  } catch (error) {
    console.error('Failed:', error);
  }
}

// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================

// 1. Use useMutation for the write operations.
// 2. Use the mutationFn for the fetch call.
// 3. Use the state flags for the UI.
// 4. Use invalidateQueries after the success.
// 5. Use setQueryData for the known result.
// 6. Use onMutate for the optimistic update.
// 7. Use onError for the rollback.
// 8. Use onSettled for the reconciliation.
// 9. Use mutateAsync for the sequential operations.
// 10. Use the queryClient for the cache methods.

The ten parts showed the basic mutation, the state flags, the invalidation, the setQueryData, the optimistic update, the temporary ID, the edit mutation, the delete mutation, the sequential mutations, and the complete pattern.


Quick Reference

The useMutation Options

OptionPurpose
mutationFnThe write function
onMutateBefore the request
onSuccessOn the success
onErrorOn the failure
onSettledAfter both

The Callbacks

CallbackArguments
onMutatevariables
onSuccessdata, variables, context
onErrorerror, variables, context
onSettleddata, error, variables, context

The Returned Object

PropertyPurpose
mutateThe fire-and-forget call
mutateAsyncThe promise-based call
isPendingThe mutation is in progress
isErrorThe mutation failed
isSuccessThe mutation succeeded
errorThe error object
dataThe resolved value
resetClear the state

The Cache Methods

MethodPurpose
invalidateQueriesMark stale and refetch
setQueryDataWrite to the cache
getQueryDataRead from the cache
removeQueriesRemove from the cache
cancelQueriesCancel the in-flight refetches

The Optimistic Update

CallbackPurpose
onMutateCancel, save, write
onErrorRollback
onSettledInvalidate

Best Practices

✅ Do This:

// Use the mutationFn for the write.
useMutation({ mutationFn: addTodo });  // ✅

// Use the state flags for the UI.
{isPending && <Spinner />}  // ✅

// Use invalidateQueries after the success.
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] });  // ✅

// Use setQueryData for the known result.
onSuccess: (newTodo) => queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);  // ✅

// Use cancelQueries before the optimistic update.
await queryClient.cancelQueries({ queryKey: ['todos'] });  // ✅

// Use the context for the rollback.
onError: (err, vars, context) => queryClient.setQueryData(['todos'], context.previous);  // ✅

// Use onSettled for the reconciliation.
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] });  // ✅

❌ Don’t Do This:

// Don't forget the invalidation.
useMutation({ mutationFn: addTodo });  // ❌ The list is stale

// Don't use the mutation without the state.
mutation.mutate(data);  // ⚠️ No spinner, no error

// Don't forget the rollback.
onMutate: async () => { ... }  // ❌ The optimistic value remains on error

// Don't forget the cancel.
onMutate: async () => { ... }  // ❌ The in-flight refetch overwrites

// Don't forget the temporary ID.
setQueryData(['todos'], (old) => [...old, newTodo]);  // ❌ The key is undefined

// Don't use mutateAsync without try/catch.
await mutation.mutateAsync(data);  // ❌ The error is unhandled

// Don't invalidate everything.
invalidateQueries();  // ❌ Refetches the whole cache

Common Pitfalls

PitfallWhy It HappensFix
List is staleNo invalidationCall invalidateQueries
Optimistic value remainsNo rollbackRestore in onError
Refetch overwritesNo cancelQueriesCancel before the write
Duplicate keyNo temporary IDAdd a temp ID
Unhandled errormutateAsync without tryUse try/catch
Spinner does not showNo isPendingDestructure the flag
Cache inconsistencyNo reconciliationUse onSettled

Real-World Examples

1. Basic Mutation

useMutation({ mutationFn: addTodo });

2. Invalidation

onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] });

3. SetQueryData

onSuccess: (newTodo) => queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);

4. Optimistic Update

onMutate: async (newTodo) => {
  await queryClient.cancelQueries({ queryKey: ['todos'] });
  const previous = queryClient.getQueryData(['todos']);
  queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
  return { previous };
}

5. Rollback

onError: (err, vars, context) => queryClient.setQueryData(['todos'], context.previous);

6. Reconciliation

onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] });

7. Temporary ID

const optimistic = { ...newTodo, id: `temp-${Date.now()}` };

8. Edit

onSuccess: (updated) => queryClient.setQueryData(['todo', id], updated);

9. Delete

onSuccess: () => queryClient.removeQueries({ queryKey: ['todo', id] });

10. Sequential

const todo = await createMutation.mutateAsync(data);
await assignMutation.mutateAsync({ todoId: todo.id });

Visual

The Mutation Lifecycle

┌────────────────────────────────────────────────────────────┐
│  THE MUTATION LIFECYCLE                                    │
│                                                            │
│  mutate(variables)                                         │
│    ↓                                                       │
│  onMutate(variables)                                       │
│    → cancel the queries                                    │
│    → save the previous value                               │
│    → write the optimistic value                            │
│    → return the context                                    │
│    ↓                                                       │
│  mutationFn(variables)                                     │
│    ↓                                                       │
│  ┌────────────────────┬────────────────────┐              │
│  │  onSuccess         │  onError           │              │
│  │  (data, vars, ctx) │  (err, vars, ctx)  │              │
│  │  → invalidate      │  → rollback        │              │
│  └────────────────────┴────────────────────┘              │
│    ↓                                                       │
│  onSettled(data, error, variables, context)                │
│    → invalidate the cache                                  │
└────────────────────────────────────────────────────────────┘

The Invalidation

┌────────────────────────────────────────────────────────────┐
│  THE INVALIDATION                                          │
│                                                            │
│  BEFORE                                                    │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Cache: ['todos'] = [A, B]                         │     │
│  │  Server: [A, B, C]                                 │     │
│  │  The cache is stale.                               │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        │  invalidateQueries               │
│                        v                                   │
│  AFTER                                                     │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Cache: ['todos'] marked stale                     │     │
│  │  The active queries refetch.                       │     │
│  │  Cache: ['todos'] = [A, B, C]                      │     │
│  │  The UI updates.                                   │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The Optimistic Update

┌────────────────────────────────────────────────────────────┐
│  THE OPTIMISTIC UPDATE                                     │
│                                                            │
│  1. onMutate:                                              │
│     - cancelQueries(['todos'])                             │
│     - previous = [A, B]                                    │
│     - setQueryData(['todos'], [A, B, C-temp])              │
│                                                            │
│  2. The UI shows [A, B, C-temp] immediately.               │
│                                                            │
│  3. The server responds:                                   │
│     - success: onSettled invalidates, the cache is [A, B, C]│
│     - error: onError restores [A, B]                       │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  The optimistic value is shown instantly.          │     │
│  │  The rollback is automatic on error.               │     │
│  │  The reconciliation is the onSettled invalidation. │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The setQueryData vs invalidateQueries

┌────────────────────────────────────────────────────────────┐
│  setQueryData vs invalidateQueries                         │
│                                                            │
│  setQueryData                                              │
│  ┌──────────────────────────────────────────────────┐     │
│  │  onSuccess: (newTodo) => {                         │     │
│  │    queryClient.setQueryData(['todos'],             │     │
│  │      (old) => [...old, newTodo]);                  │     │
│  │  }                                                │     │
│  │                                                   │     │
│  │  No network request. Instant.                     │     │
│  │  Use when the mutation returns the value.         │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  invalidateQueries                                         │
│  ┌──────────────────────────────────────────────────┐     │
│  │  onSuccess: () => {                                │     │
│  │    queryClient.invalidateQueries({                 │     │
│  │      queryKey: ['todos'] });                       │     │
│  │  }                                                │     │
│  │                                                   │     │
│  │  A refetch is triggered.                          │     │
│  │  Use when the server computes the result.         │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
useMutationThe write hook
mutationFnThe write function
mutateThe fire-and-forget call
mutateAsyncThe promise-based call
onMutateBefore the request
onSuccessOn the success
onErrorOn the failure
onSettledAfter both
invalidateQueriesMark stale and refetch
setQueryDataWrite to the cache

Key takeaways:

  • The useMutation hook is the write counterpart of useQuery. It takes the mutation function, it exposes the state flags, and it provides the callbacks for the side effects. The mutation does not touch the cache by default.
  • The invalidateQueries call is the bridge between the write and the read. After the mutation succeeds, the affected queries are marked stale, and the active queries refetch. The prefix matching invalidates the hierarchical keys.
  • The setQueryData method writes the known result directly. The method is faster than the invalidation because it does not require a network request. The invalidation is the right choice when the server computes the result.
  • The optimistic update shows the result before the server confirms. The onMutate callback cancels the in-flight queries, saves the previous value, and writes the optimistic value. The onError callback rolls back, and the onSettled callback invalidates.
  • The cancelQueries call prevents the overwrite. An in-flight refetch that completes after the optimistic write overwrites the optimistic value. The cancel is the fix.
  • The temporary ID is required for the optimistic item. The list key must be unique. The temp-${Date.now()} ID is replaced by the server ID when the cache is invalidated.
  • The mutateAsync function is the sequential form. It returns a promise, and it requires a try/catch for the error handling. The mutate function is the fire-and-forget form.
  • The callbacks receive the context. The onMutate returns the context, and the onError and onSettled receive it. The context is the rollback data.

Remember: The useMutation hook is the entry point for the write operations in a TanStack Query application. The mutationFn writes the data, the state flags tell the component what to render, and the callbacks handle the side effects. The invalidateQueries call marks the cache stale, and the active queries refetch. The setQueryData method writes the known result, and the optimistic update shows the result before the server confirms. The cancelQueries call prevents the overwrite, the temporary ID keeps the list key unique, and the onSettled invalidation reconciles the cache with the server. The result is an application where the writes update the UI instantly, the cache stays in sync, and the errors roll back the optimistic changes.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!