Move redux state to react-query - tags states (#6941)

* Move redux state to react-query - tags states

* Add release notes for PR #6941

* Cleanup sendThrow

* Cleanup

* Update import

* Fix import

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Joel Jeremy Marquez
2026-02-13 11:33:21 -08:00
committed by GitHub
parent ca944baee5
commit a0378c10a9
14 changed files with 226 additions and 213 deletions

View File

@@ -22,16 +22,17 @@ import {
useSelected,
} from '@desktop-client/hooks/useSelected';
import { useTags } from '@desktop-client/hooks/useTags';
import { useDispatch } from '@desktop-client/redux';
import { deleteAllTags, findTags } from '@desktop-client/tags/tagsSlice';
import {
useDeleteTagsMutation,
useDiscoverTagsMutation,
} from '@desktop-client/tags';
export function ManageTags() {
const { t } = useTranslation();
const dispatch = useDispatch();
const [filter, setFilter] = useState('');
const [hoveredTag, setHoveredTag] = useState<string>();
const [create, setCreate] = useState(false);
const tags = useTags();
const { data: tags = [] } = useTags();
const filteredTags = useMemo(() => {
return filter === ''
@@ -43,10 +44,19 @@ export function ManageTags() {
const selectedInst = useSelected('manage-tags', filteredTags, []);
const { mutate: discoverTags } = useDiscoverTagsMutation();
const { mutate: deleteTags } = useDeleteTagsMutation();
const onDeleteSelected = useCallback(async () => {
dispatch(deleteAllTags([...selectedInst.items]));
selectedInst.dispatch({ type: 'select-none' });
}, [dispatch, selectedInst]);
deleteTags(
{ ids: [...selectedInst.items] },
{
onSuccess: () => {
selectedInst.dispatch({ type: 'select-none' });
},
},
);
}, [deleteTags, selectedInst]);
return (
<SelectedProvider instance={selectedInst}>
@@ -75,7 +85,7 @@ export function ManageTags() {
<SvgAdd width={10} height={10} style={{ marginRight: 3 }} />
<Trans>Add New</Trans>
</Button>
<Button variant="bare" onPress={() => dispatch(findTags())}>
<Button variant="bare" onPress={() => discoverTags()}>
<SvgSearchAlternate
width={10}
height={10}

View File

@@ -18,8 +18,7 @@ import {
import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
import { useProperFocus } from '@desktop-client/hooks/useProperFocus';
import { useTagCSS } from '@desktop-client/hooks/useTagCSS';
import { useDispatch } from '@desktop-client/redux';
import { createTag } from '@desktop-client/tags/tagsSlice';
import { useCreateTagMutation } from '@desktop-client/tags';
type TagCreationRowProps = {
tags: TagEntity[];
@@ -28,7 +27,6 @@ type TagCreationRowProps = {
export const TagCreationRow = ({ onClose, tags }: TagCreationRowProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const [tag, setTag] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState<string | null>(null);
@@ -67,12 +65,14 @@ export const TagCreationRow = ({ onClose, tags }: TagCreationRowProps) => {
);
};
const { mutate: createTag } = useCreateTagMutation();
const onAddTag = () => {
if (!isTagValid()) {
return;
}
dispatch(createTag({ tag, color, description }));
createTag({ tag: { tag, color, description } });
resetInputs();
};

View File

@@ -6,8 +6,7 @@ import { ColorPicker } from '@actual-app/components/color-picker';
import type { TagEntity } from 'loot-core/types/models';
import { useTagCSS } from '@desktop-client/hooks/useTagCSS';
import { useDispatch } from '@desktop-client/redux';
import { updateTag } from '@desktop-client/tags/tagsSlice';
import { useUpdateTagMutation } from '@desktop-client/tags';
type TagEditorProps = {
tag: TagEntity;
@@ -15,8 +14,8 @@ type TagEditorProps = {
};
export const TagEditor = ({ tag, ref }: TagEditorProps) => {
const dispatch = useDispatch();
const getTagCSS = useTagCSS();
const { mutate: updateTag } = useUpdateTagMutation();
const formattedTag = <>#{tag.tag}</>;
@@ -24,7 +23,7 @@ export const TagEditor = ({ tag, ref }: TagEditorProps) => {
<ColorPicker
value={tag.color ?? undefined}
onChange={color => {
dispatch(updateTag({ ...tag, color: color.toString('hex') }));
updateTag({ tag: { ...tag, color: color.toString('hex') } });
}}
>
<Button variant="bare" className={getTagCSS(tag.tag)} ref={ref}>

View File

@@ -22,8 +22,10 @@ import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useProperFocus } from '@desktop-client/hooks/useProperFocus';
import { useSelectedDispatch } from '@desktop-client/hooks/useSelected';
import { useDispatch } from '@desktop-client/redux';
import { deleteTag, updateTag } from '@desktop-client/tags/tagsSlice';
import {
useDeleteTagMutation,
useUpdateTagMutation,
} from '@desktop-client/tags';
type TagRowProps = {
tag: TagEntity;
@@ -37,7 +39,6 @@ type TagRowProps = {
export const TagRow = memo(
({ tag, hovered, selected, onHover, focusedField, onEdit }: TagRowProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const dispatchSelected = useSelectedDispatch();
const borderColor = selected ? theme.tableBorderSelected : 'none';
@@ -50,9 +51,11 @@ export const TagRow = memo(
const { setMenuOpen, menuOpen, handleContextMenu, position } =
useContextMenu();
const navigate = useNavigate();
const { mutate: updateTag } = useUpdateTagMutation();
const { mutate: deleteTag } = useDeleteTagMutation();
const onUpdate = (description: string) => {
dispatch(updateTag({ ...tag, description }));
updateTag({ tag: { ...tag, description } });
};
const onShowActivity = () => {
@@ -108,7 +111,7 @@ export const TagRow = memo(
onMenuSelect={name => {
switch (name) {
case 'delete':
dispatch(deleteTag(tag));
deleteTag({ id: tag.id });
break;
default:
throw new Error(`Unrecognized menu option: ${name}`);

View File

@@ -10,7 +10,7 @@ import { useTags } from './useTags';
import { useTheme } from '@desktop-client/style';
export function useTagCSS() {
const tags = useTags();
const { data: tags = [] } = useTags();
const [theme] = useTheme();
return useCallback(

View File

@@ -1,20 +1,7 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useInitialMount } from './useInitialMount';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { getTags } from '@desktop-client/tags/tagsSlice';
import { tagQueries } from '@desktop-client/tags/queries';
export function useTags() {
const dispatch = useDispatch();
const isInitialMount = useInitialMount();
const isTagsDirty = useSelector(state => state.tags.isTagsDirty);
useEffect(() => {
if (isInitialMount || isTagsDirty) {
dispatch(getTags());
}
}, [dispatch, isInitialMount, isTagsDirty]);
return useSelector(state => state.tags.tags);
return useQuery(tagQueries.list());
}

View File

@@ -27,7 +27,6 @@ import * as payeesSlice from './payees/payeesSlice';
import * as prefsSlice from './prefs/prefsSlice';
import { aqlQuery } from './queries/aqlQuery';
import { configureAppStore } from './redux/store';
import * as tagsSlice from './tags/tagsSlice';
import * as transactionsSlice from './transactions/transactionsSlice';
import { redo, undo } from './undo';
import * as usersSlice from './users/usersSlice';
@@ -47,7 +46,6 @@ const boundActions = bindActionCreators(
...payeesSlice.actions,
...prefsSlice.actions,
...transactionsSlice.actions,
...tagsSlice.actions,
...usersSlice.actions,
},
store.dispatch,

View File

@@ -35,10 +35,6 @@ import {
name as prefsSliceName,
reducer as prefsSliceReducer,
} from '@desktop-client/prefs/prefsSlice';
import {
name as tagsSliceName,
reducer as tagsSliceReducer,
} from '@desktop-client/tags/tagsSlice';
import {
name as transactionsSliceName,
reducer as transactionsSliceReducer,
@@ -57,7 +53,6 @@ const rootReducer = combineReducers({
[payeesSliceName]: payeesSliceReducer,
[prefsSliceName]: prefsSliceReducer,
[transactionsSliceName]: transactionsSliceReducer,
[tagsSliceName]: tagsSliceReducer,
[usersSliceName]: usersSliceReducer,
});

View File

@@ -0,0 +1,2 @@
export * from './queries';
export * from './mutations';

View File

@@ -0,0 +1,158 @@
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { send } from 'loot-core/platform/client/connection';
import type { TagEntity } from 'loot-core/types/models';
import { tagQueries } from './queries';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
function invalidateQueries(queryClient: QueryClient, queryKey?: QueryKey) {
queryClient.invalidateQueries({
queryKey: queryKey ?? tagQueries.lists(),
});
}
function dispatchErrorNotification(
dispatch: AppDispatch,
message: string,
error?: Error,
) {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
pre: error ? error.message : undefined,
},
}),
);
}
type CreateTagPayload = {
tag: Omit<TagEntity, 'id'>;
};
export function useCreateTagMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ tag }: CreateTagPayload) => {
return await send('tags-create', tag);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error creating tag:', error);
dispatchErrorNotification(
dispatch,
t('There was an error creating the tag. Please try again.'),
error,
);
},
});
}
type UpdateTagPayload = {
tag: TagEntity;
};
export function useUpdateTagMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ tag }: UpdateTagPayload) => {
return await send('tags-update', tag);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error updating tag:', error);
dispatchErrorNotification(
dispatch,
t('There was an error updating the tag. Please try again.'),
error,
);
},
});
}
type DeleteTagPayload = {
id: TagEntity['id'];
};
export function useDeleteTagMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ id }: DeleteTagPayload) => {
return await send('tags-delete', { id });
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error deleting tag:', error);
dispatchErrorNotification(
dispatch,
t('There was an error deleting the tag. Please try again.'),
error,
);
},
});
}
type DeleteTagsPayload = {
ids: Array<TagEntity['id']>;
};
export function useDeleteTagsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ ids }: DeleteTagsPayload) => {
return await send('tags-delete-all', ids);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error deleting tags:', error);
dispatchErrorNotification(
dispatch,
t('There was an error deleting the tags. Please try again.'),
error,
);
},
});
}
export function useDiscoverTagsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async () => {
return await send('tags-discover');
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error discovering tags:', error);
dispatchErrorNotification(
dispatch,
t('There was an error discovering the tags. Please try again.'),
error,
);
},
});
}

View File

@@ -0,0 +1,20 @@
import { queryOptions } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/connection';
import type { TagEntity } from 'loot-core/types/models';
export const tagQueries = {
all: () => ['tags'],
lists: () => [...tagQueries.all(), 'lists'],
list: () =>
queryOptions<TagEntity[]>({
queryKey: [...tagQueries.lists()],
queryFn: async () => {
const tags: TagEntity[] = await send('tags-get');
return tags;
},
placeholderData: [],
// Manually invalidated when tags change
staleTime: Infinity,
}),
};

View File

@@ -1,165 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
import { send } from 'loot-core/platform/client/connection';
import type { TagEntity } from 'loot-core/types/models';
import { resetApp } from '@desktop-client/app/appSlice';
import { createAppAsyncThunk } from '@desktop-client/redux';
const sliceName = 'tags';
type TagsState = {
tags: TagEntity[];
isTagsLoading: boolean;
isTagsLoaded: boolean;
isTagsDirty: boolean;
};
const initialState: TagsState = {
tags: [],
isTagsLoading: false,
isTagsLoaded: false,
isTagsDirty: false,
};
const tagSlice = createSlice({
name: sliceName,
initialState,
reducers: {
markTagsDirty(state) {
_markTagsDirty(state);
},
},
extraReducers: builder => {
builder.addCase(resetApp, () => initialState);
builder.addCase(createTag.fulfilled, _markTagsDirty);
builder.addCase(deleteTag.fulfilled, _markTagsDirty);
builder.addCase(deleteAllTags.fulfilled, _markTagsDirty);
builder.addCase(updateTag.fulfilled, _markTagsDirty);
builder.addCase(reloadTags.fulfilled, (state, action) => {
_loadTags(state, action.payload);
});
builder.addCase(reloadTags.rejected, state => {
state.isTagsLoading = false;
});
builder.addCase(reloadTags.pending, state => {
state.isTagsLoading = true;
});
builder.addCase(getTags.fulfilled, (state, action) => {
_loadTags(state, action.payload);
});
builder.addCase(getTags.rejected, state => {
state.isTagsLoading = false;
});
builder.addCase(getTags.pending, state => {
state.isTagsLoading = true;
});
builder.addCase(findTags.fulfilled, (state, action) => {
_loadTags(state, action.payload);
});
builder.addCase(findTags.rejected, state => {
state.isTagsLoading = false;
});
builder.addCase(findTags.pending, state => {
state.isTagsLoading = true;
});
},
});
export const getTags = createAppAsyncThunk(
`${sliceName}/getTags`,
async () => {
const tags: TagEntity[] = await send('tags-get');
return tags;
},
{
condition: (_, { getState }) => {
const { tags } = getState();
return !tags.isTagsLoading && (tags.isTagsDirty || !tags.isTagsLoaded);
},
},
);
export const reloadTags = createAppAsyncThunk(
`${sliceName}/reloadTags`,
async () => {
const tags: TagEntity[] = await send('tags-get');
return tags;
},
);
export const createTag = createAppAsyncThunk(
`${sliceName}/createTag`,
async ({ tag, color, description }: Omit<TagEntity, 'id'>) => {
const id = await send('tags-create', { tag, color, description });
return id;
},
);
export const deleteTag = createAppAsyncThunk(
`${sliceName}/deleteTag`,
async (tag: TagEntity) => {
const id = await send('tags-delete', tag);
return id;
},
);
export const deleteAllTags = createAppAsyncThunk(
`${sliceName}/deleteAllTags`,
async (ids: Array<TagEntity['id']>) => {
const id = await send('tags-delete-all', ids);
return id;
},
);
export const updateTag = createAppAsyncThunk(
`${sliceName}/updateTag`,
async (tag: TagEntity) => {
const id = await send('tags-update', tag);
return id;
},
);
export const findTags = createAppAsyncThunk(
`${sliceName}/findTags`,
async () => {
const tags: TagEntity[] = await send('tags-find');
return tags;
},
);
export const { name, reducer, getInitialState } = tagSlice;
export const actions = {
...tagSlice.actions,
getTags,
reloadTags,
createTag,
deleteTag,
deleteAllTags,
updateTag,
findTags,
};
export const { markTagsDirty } = tagSlice.actions;
function _loadTags(state: TagsState, tags: TagsState['tags']) {
state.tags = tags;
state.isTagsLoading = false;
state.isTagsLoaded = true;
state.isTagsDirty = false;
}
function _markTagsDirty(state: TagsState) {
state.isTagsDirty = true;
}

View File

@@ -11,7 +11,7 @@ export type TagsHandlers = {
'tags-delete': typeof deleteTag;
'tags-delete-all': typeof deleteAllTags;
'tags-update': typeof updateTag;
'tags-find': typeof findTags;
'tags-discover': typeof discoverTags;
};
export const app = createApp<TagsHandlers>();
@@ -20,7 +20,7 @@ app.method('tags-create', mutator(undoable(createTag)));
app.method('tags-delete', mutator(undoable(deleteTag)));
app.method('tags-delete-all', mutator(deleteAllTags));
app.method('tags-update', mutator(undoable(updateTag)));
app.method('tags-find', mutator(findTags));
app.method('tags-discover', mutator(discoverTags));
async function getTags(): Promise<TagEntity[]> {
return await db.getTags();
@@ -77,7 +77,7 @@ async function updateTag(
return tag;
}
async function findTags(): Promise<TagEntity[]> {
async function discoverTags(): Promise<TagEntity[]> {
const taggedNotes = await db.findTags();
const tags = await getTags();

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [joel-jeremy]
---
Migrate tag management from Redux to React Query for improved state handling and performance.