From ab8c3c018ae8e05539c1a0b35eb312739d58be59 Mon Sep 17 00:00:00 2001
From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Date: Tue, 12 Mar 2024 15:01:38 -0700
Subject: [PATCH] [Maintenance] Reorganize mobile components (#2425)

* Reorganize mobile components

* Fix lint error

* Cleanup

* Fix lint error

* Release notes

* Cleanup

* Cleanup useActions

* useDebounceCallback
---
 packages/desktop-client/package.json          |   1 +
 .../src/components/FinancesApp.tsx            |   2 +-
 .../src/components/accounts/Account.jsx       |   8 +-
 .../src/components/accounts/Balance.jsx       |   2 +-
 .../src/components/accounts/Header.jsx        |   2 +-
 .../{responsive => mobile}/PullToRefresh.tsx  |   0
 .../accounts/Account.jsx}                     | 115 ++--
 .../accounts/AccountDetails.jsx}              |  36 +-
 .../accounts/Accounts.jsx}                    |  30 +-
 .../budget/BudgetTable.jsx}                   |  69 +-
 .../budget/ListItem.tsx}                      |   4 +-
 .../budget/index.tsx}                         | 261 ++++----
 .../FocusableAmountInput.jsx}                 |  32 +-
 .../mobile/transactions/ListBox.jsx           |  53 ++
 .../mobile/transactions/ListBoxSection.jsx    |  61 ++
 .../components/mobile/transactions/Option.jsx |  33 +
 .../mobile/transactions/Transaction.jsx       | 209 ++++++
 .../transactions/TransactionEdit.jsx}         | 607 +++---------------
 .../mobile/transactions/TransactionList.jsx   | 117 ++++
 .../src/components/responsive/narrow.ts       |   6 +-
 .../transactions/SelectedTransactions.jsx     |   3 +-
 .../transactions/TransactionsTable.jsx        | 113 +---
 .../transactions/TransactionsTable.test.jsx   |   3 +-
 .../src/hooks/useSplitsExpanded.jsx           | 106 +++
 .../src/client/state-types/modals.d.ts        |   5 +-
 packages/loot-core/src/shared/transactions.ts |   4 +
 upcoming-release-notes/2425.md                |   6 +
 yarn.lock                                     |  12 +
 28 files changed, 967 insertions(+), 933 deletions(-)
 rename packages/desktop-client/src/components/{responsive => mobile}/PullToRefresh.tsx (100%)
 rename packages/desktop-client/src/components/{accounts/MobileAccount.jsx => mobile/accounts/Account.jsx} (71%)
 rename packages/desktop-client/src/components/{accounts/MobileAccountDetails.jsx => mobile/accounts/AccountDetails.jsx} (85%)
 rename packages/desktop-client/src/components/{accounts/MobileAccounts.jsx => mobile/accounts/Accounts.jsx} (90%)
 rename packages/desktop-client/src/components/{budget/MobileBudgetTable.jsx => mobile/budget/BudgetTable.jsx} (96%)
 rename packages/desktop-client/src/components/{budget/MobileTable.tsx => mobile/budget/ListItem.tsx} (86%)
 rename packages/desktop-client/src/components/{budget/MobileBudget.tsx => mobile/budget/index.tsx} (65%)
 rename packages/desktop-client/src/components/mobile/{MobileAmountInput.jsx => transactions/FocusableAmountInput.jsx} (90%)
 create mode 100644 packages/desktop-client/src/components/mobile/transactions/ListBox.jsx
 create mode 100644 packages/desktop-client/src/components/mobile/transactions/ListBoxSection.jsx
 create mode 100644 packages/desktop-client/src/components/mobile/transactions/Option.jsx
 create mode 100644 packages/desktop-client/src/components/mobile/transactions/Transaction.jsx
 rename packages/desktop-client/src/components/{transactions/MobileTransaction.jsx => mobile/transactions/TransactionEdit.jsx} (67%)
 create mode 100644 packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx
 create mode 100644 packages/desktop-client/src/hooks/useSplitsExpanded.jsx
 create mode 100644 upcoming-release-notes/2425.md

diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json
index 9ade60b0f..9e4d28668 100644
--- a/packages/desktop-client/package.json
+++ b/packages/desktop-client/package.json
@@ -65,6 +65,7 @@
     "sass": "^1.70.0",
     "swc-loader": "^0.2.3",
     "terser-webpack-plugin": "^5.3.10",
+    "usehooks-ts": "^3.0.1",
     "uuid": "^9.0.1",
     "vite": "^5.0.12",
     "vite-plugin-pwa": "^0.19.0",
diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx
index 8024afe79..1dbf53dac 100644
--- a/packages/desktop-client/src/components/FinancesApp.tsx
+++ b/packages/desktop-client/src/components/FinancesApp.tsx
@@ -33,6 +33,7 @@ import { View } from './common/View';
 import { GlobalKeys } from './GlobalKeys';
 import { ManageRulesPage } from './ManageRulesPage';
 import { MobileNavTabs } from './mobile/MobileNavTabs';
+import { TransactionEdit } from './mobile/transactions/TransactionEdit';
 import { Modals } from './Modals';
 import { Notifications } from './Notifications';
 import { ManagePayeesPage } from './payees/ManagePayeesPage';
@@ -43,7 +44,6 @@ import { Settings } from './settings';
 import { FloatableSidebar } from './sidebar';
 import { SidebarProvider } from './sidebar/SidebarProvider';
 import { Titlebar, TitlebarProvider } from './Titlebar';
-import { TransactionEdit } from './transactions/MobileTransaction';
 
 function NarrowNotSupported({
   redirectTo = '/budget',
diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx
index a0290d431..8d6923cb4 100644
--- a/packages/desktop-client/src/components/accounts/Account.jsx
+++ b/packages/desktop-client/src/components/accounts/Account.jsx
@@ -34,15 +34,15 @@ import { useFailedAccounts } from '../../hooks/useFailedAccounts';
 import { useLocalPref } from '../../hooks/useLocalPref';
 import { usePayees } from '../../hooks/usePayees';
 import { SelectedProviderWithItems } from '../../hooks/useSelected';
+import {
+  SplitsExpandedProvider,
+  useSplitsExpanded,
+} from '../../hooks/useSplitsExpanded';
 import { styles, theme } from '../../style';
 import { Button } from '../common/Button';
 import { Text } from '../common/Text';
 import { View } from '../common/View';
 import { TransactionList } from '../transactions/TransactionList';
-import {
-  SplitsExpandedProvider,
-  useSplitsExpanded,
-} from '../transactions/TransactionsTable';
 
 import { AccountHeader } from './Header';
 
diff --git a/packages/desktop-client/src/components/accounts/Balance.jsx b/packages/desktop-client/src/components/accounts/Balance.jsx
index 5c0ca0272..72770444e 100644
--- a/packages/desktop-client/src/components/accounts/Balance.jsx
+++ b/packages/desktop-client/src/components/accounts/Balance.jsx
@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { isPreviewId } from 'loot-core/shared/transactions';
 import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules';
 import { q } from 'loot-core/src/shared/query';
 import { getScheduledAmount } from 'loot-core/src/shared/schedules';
@@ -14,7 +15,6 @@ import { PrivacyFilter } from '../PrivacyFilter';
 import { CellValue } from '../spreadsheet/CellValue';
 import { useFormat } from '../spreadsheet/useFormat';
 import { useSheetValue } from '../spreadsheet/useSheetValue';
-import { isPreviewId } from '../transactions/TransactionsTable';
 
 function DetailedBalance({ name, balance, isExactBalance = true }) {
   const format = useFormat();
diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx
index 3d968d19a..078203fa6 100644
--- a/packages/desktop-client/src/components/accounts/Header.jsx
+++ b/packages/desktop-client/src/components/accounts/Header.jsx
@@ -1,6 +1,7 @@
 import React, { useState, useRef } from 'react';
 
 import { useLocalPref } from '../../hooks/useLocalPref';
+import { useSplitsExpanded } from '../../hooks/useSplitsExpanded';
 import { useSyncServerStatus } from '../../hooks/useSyncServerStatus';
 import { AnimatedLoading } from '../../icons/AnimatedLoading';
 import { SvgAdd } from '../../icons/v1';
@@ -26,7 +27,6 @@ import { FiltersStack } from '../filters/FiltersStack';
 import { KeyHandlers } from '../KeyHandlers';
 import { NotesButton } from '../NotesButton';
 import { SelectedTransactionsButton } from '../transactions/SelectedTransactions';
-import { useSplitsExpanded } from '../transactions/TransactionsTable';
 
 import { Balances } from './Balance';
 import { ReconcilingMessage, ReconcileTooltip } from './Reconcile';
diff --git a/packages/desktop-client/src/components/responsive/PullToRefresh.tsx b/packages/desktop-client/src/components/mobile/PullToRefresh.tsx
similarity index 100%
rename from packages/desktop-client/src/components/responsive/PullToRefresh.tsx
rename to packages/desktop-client/src/components/mobile/PullToRefresh.tsx
diff --git a/packages/desktop-client/src/components/accounts/MobileAccount.jsx b/packages/desktop-client/src/components/mobile/accounts/Account.jsx
similarity index 71%
rename from packages/desktop-client/src/components/accounts/MobileAccount.jsx
rename to packages/desktop-client/src/components/mobile/accounts/Account.jsx
index 0b06e280e..8a16a294c 100644
--- a/packages/desktop-client/src/components/accounts/MobileAccount.jsx
+++ b/packages/desktop-client/src/components/mobile/accounts/Account.jsx
@@ -1,10 +1,9 @@
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
 import { useDispatch, useSelector } from 'react-redux';
 import { useParams } from 'react-router-dom';
 
-import debounce from 'debounce';
 import memoizeOne from 'memoize-one';
-import { bindActionCreators } from 'redux';
+import { useDebounceCallback } from 'usehooks-ts';
 
 import * as actions from 'loot-core/src/client/actions';
 import {
@@ -19,20 +18,20 @@ import {
   ungroupTransactions,
 } from 'loot-core/src/shared/transactions';
 
-import { useAccounts } from '../../hooks/useAccounts';
-import { useCategories } from '../../hooks/useCategories';
-import { useDateFormat } from '../../hooks/useDateFormat';
-import { useLocalPref } from '../../hooks/useLocalPref';
-import { useLocalPrefs } from '../../hooks/useLocalPrefs';
-import { useNavigate } from '../../hooks/useNavigate';
-import { usePayees } from '../../hooks/usePayees';
-import { useSetThemeColor } from '../../hooks/useSetThemeColor';
-import { theme, styles } from '../../style';
-import { Button } from '../common/Button';
-import { Text } from '../common/Text';
-import { View } from '../common/View';
-
-import { AccountDetails } from './MobileAccountDetails';
+import { useAccounts } from '../../../hooks/useAccounts';
+import { useCategories } from '../../../hooks/useCategories';
+import { useDateFormat } from '../../../hooks/useDateFormat';
+import { useLocalPref } from '../../../hooks/useLocalPref';
+import { useLocalPrefs } from '../../../hooks/useLocalPrefs';
+import { useNavigate } from '../../../hooks/useNavigate';
+import { usePayees } from '../../../hooks/usePayees';
+import { useSetThemeColor } from '../../../hooks/useSetThemeColor';
+import { theme, styles } from '../../../style';
+import { Button } from '../../common/Button';
+import { Text } from '../../common/Text';
+import { View } from '../../common/View';
+
+import { AccountDetails } from './AccountDetails';
 
 const getSchedulesTransform = memoizeOne((id, hasSearch) => {
   let filter = queries.getAccountFilter(id, '_account');
@@ -74,15 +73,13 @@ function PreviewTransactions({ children }) {
   );
 }
 
-let paged;
-
 export function Account(props) {
   const accounts = useAccounts();
   const payees = usePayees();
 
   const navigate = useNavigate();
   const [transactions, setTransactions] = useState([]);
-  const [searchText, setSearchText] = useState('');
+  const [isSearching, setIsSearching] = useState(false);
   const [currentQuery, setCurrentQuery] = useState();
 
   const newTransactions = useSelector(state => state.queries.newTransactions);
@@ -100,32 +97,30 @@ export function Account(props) {
   };
 
   const dispatch = useDispatch();
-  const actionCreators = useMemo(
-    () => bindActionCreators(actions, dispatch),
-    [dispatch],
-  );
 
   const { id: accountId } = useParams();
 
-  const makeRootQuery = () => queries.makeTransactionsQuery(accountId);
+  const makeRootQuery = useCallback(
+    () => queries.makeTransactionsQuery(accountId),
+    [accountId],
+  );
 
-  const updateQuery = query => {
-    if (paged) {
-      paged.unsubscribe();
-    }
+  const paged = useRef(null);
 
-    paged = pagedQuery(
+  const updateQuery = useCallback(query => {
+    paged.current?.unsubscribe();
+    paged.current = pagedQuery(
       query.options({ splits: 'grouped' }).select('*'),
       data => setTransactions(data),
-      { pageCount: 150, mapper: ungroupTransactions },
+      { pageCount: 10, mapper: ungroupTransactions },
     );
-  };
+  }, []);
 
-  const fetchTransactions = async () => {
+  const fetchTransactions = useCallback(async () => {
     const query = makeRootQuery();
     setCurrentQuery(query);
     updateQuery(query);
-  };
+  }, [makeRootQuery, updateQuery]);
 
   useEffect(() => {
     let unlisten;
@@ -138,43 +133,49 @@ export function Account(props) {
             tables.includes('category_mapping') ||
             tables.includes('payee_mapping')
           ) {
-            paged?.run();
+            paged.current?.run();
           }
 
           if (tables.includes('payees') || tables.includes('payee_mapping')) {
-            actionCreators.getPayees();
+            dispatch(actions.getPayees());
           }
         }
       });
 
       await fetchTransactions();
 
-      actionCreators.markAccountRead(accountId);
+      dispatch(actions.markAccountRead(accountId));
     }
 
     setUpAccount();
 
     return () => unlisten();
-  }, []);
+  }, [accountId, dispatch, fetchTransactions]);
 
   // Load categories if necessary.
   const categories = useCategories();
 
-  const updateSearchQuery = debounce(() => {
-    if (searchText === '' && currentQuery) {
-      updateQuery(currentQuery);
-    } else if (searchText && currentQuery) {
-      updateQuery(
-        queries.makeTransactionSearchQuery(
-          currentQuery,
-          searchText,
-          state.dateFormat,
-        ),
-      );
-    }
-  }, 150);
+  const updateSearchQuery = useDebounceCallback(
+    useCallback(
+      searchText => {
+        if (searchText === '' && currentQuery) {
+          updateQuery(currentQuery);
+        } else if (searchText && currentQuery) {
+          updateQuery(
+            queries.makeTransactionSearchQuery(
+              currentQuery,
+              searchText,
+              dateFormat,
+            ),
+          );
+        }
 
-  useEffect(updateSearchQuery, [searchText, currentQuery, state.dateFormat]);
+        setIsSearching(searchText !== '');
+      },
+      [currentQuery, dateFormat, updateQuery],
+    ),
+    150,
+  );
 
   useSetThemeColor(theme.mobileViewTheme);
 
@@ -209,9 +210,8 @@ export function Account(props) {
     return state.newTransactions.includes(id);
   };
 
-  const onSearch = async text => {
-    paged.unsubscribe();
-    setSearchText(text);
+  const onSearch = text => {
+    updateSearchQuery(text);
   };
 
   const onSelectTransaction = transaction => {
@@ -227,7 +227,7 @@ export function Account(props) {
 
   return (
     <SchedulesProvider
-      transform={getSchedulesTransform(accountId, searchText !== '')}
+      transform={getSchedulesTransform(accountId, isSearching)}
     >
       <PreviewTransactions accountId={props.accountId}>
         {prependTransactions =>
@@ -236,7 +236,6 @@ export function Account(props) {
               // This key forces the whole table rerender when the number
               // format changes
               {...state}
-              {...actionCreators}
               key={numberFormat + hideFraction}
               account={account}
               accounts={accounts}
@@ -249,7 +248,7 @@ export function Account(props) {
               balanceUncleared={balanceUncleared}
               isNewTransaction={isNewTransaction}
               onLoadMore={() => {
-                paged?.fetchNext();
+                paged.current?.fetchNext();
               }}
               onSearch={onSearch}
               onSelectTransaction={onSelectTransaction}
diff --git a/packages/desktop-client/src/components/accounts/MobileAccountDetails.jsx b/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx
similarity index 85%
rename from packages/desktop-client/src/components/accounts/MobileAccountDetails.jsx
rename to packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx
index b1dbe957f..0a48c0e01 100644
--- a/packages/desktop-client/src/components/accounts/MobileAccountDetails.jsx
+++ b/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx
@@ -1,19 +1,21 @@
 import React, { useState, useMemo } from 'react';
+import { useDispatch } from 'react-redux';
 
-import { useActions } from '../../hooks/useActions';
-import { SvgAdd } from '../../icons/v1';
-import { SvgSearchAlternate } from '../../icons/v2';
-import { theme } from '../../style';
-import { ButtonLink } from '../common/ButtonLink';
-import { InputWithContent } from '../common/InputWithContent';
-import { Label } from '../common/Label';
-import { View } from '../common/View';
-import { MobileBackButton } from '../MobileBackButton';
-import { Page } from '../Page';
-import { PullToRefresh } from '../responsive/PullToRefresh';
-import { CellValue } from '../spreadsheet/CellValue';
-import { useSheetValue } from '../spreadsheet/useSheetValue';
-import { TransactionList } from '../transactions/MobileTransaction';
+import { syncAndDownload } from 'loot-core/client/actions';
+
+import { SvgAdd } from '../../../icons/v1';
+import { SvgSearchAlternate } from '../../../icons/v2';
+import { theme } from '../../../style';
+import { ButtonLink } from '../../common/ButtonLink';
+import { InputWithContent } from '../../common/InputWithContent';
+import { Label } from '../../common/Label';
+import { View } from '../../common/View';
+import { MobileBackButton } from '../../MobileBackButton';
+import { Page } from '../../Page';
+import { CellValue } from '../../spreadsheet/CellValue';
+import { useSheetValue } from '../../spreadsheet/useSheetValue';
+import { PullToRefresh } from '../PullToRefresh';
+import { TransactionList } from '../transactions/TransactionList';
 
 function TransactionSearchInput({ accountName, onSearch }) {
   const [text, setText] = useState('');
@@ -76,15 +78,14 @@ export function AccountDetails({
   onLoadMore,
   onSearch,
   onSelectTransaction,
-  pushModal,
 }) {
   const allTransactions = useMemo(() => {
     return prependTransactions.concat(transactions);
   }, [prependTransactions, transactions]);
 
-  const { syncAndDownload } = useActions();
+  const dispatch = useDispatch();
   const onRefresh = async () => {
-    await syncAndDownload(account.id);
+    await dispatch(syncAndDownload(account.id));
   };
 
   return (
@@ -207,7 +208,6 @@ export function AccountDetails({
           isNew={isNewTransaction}
           onLoadMore={onLoadMore}
           onSelect={onSelectTransaction}
-          pushModal={pushModal}
         />
       </PullToRefresh>
     </Page>
diff --git a/packages/desktop-client/src/components/accounts/MobileAccounts.jsx b/packages/desktop-client/src/components/mobile/accounts/Accounts.jsx
similarity index 90%
rename from packages/desktop-client/src/components/accounts/MobileAccounts.jsx
rename to packages/desktop-client/src/components/mobile/accounts/Accounts.jsx
index c4ff29512..02df449b0 100644
--- a/packages/desktop-client/src/components/accounts/MobileAccounts.jsx
+++ b/packages/desktop-client/src/components/mobile/accounts/Accounts.jsx
@@ -4,21 +4,21 @@ import { useDispatch, useSelector } from 'react-redux';
 import { replaceModal, syncAndDownload } from 'loot-core/src/client/actions';
 import * as queries from 'loot-core/src/client/queries';
 
-import { useAccounts } from '../../hooks/useAccounts';
-import { useCategories } from '../../hooks/useCategories';
-import { useLocalPref } from '../../hooks/useLocalPref';
-import { useNavigate } from '../../hooks/useNavigate';
-import { useSetThemeColor } from '../../hooks/useSetThemeColor';
-import { SvgAdd } from '../../icons/v1';
-import { theme, styles } from '../../style';
-import { Button } from '../common/Button';
-import { Text } from '../common/Text';
-import { TextOneLine } from '../common/TextOneLine';
-import { View } from '../common/View';
-import { MOBILE_NAV_HEIGHT } from '../mobile/MobileNavTabs';
-import { Page } from '../Page';
-import { PullToRefresh } from '../responsive/PullToRefresh';
-import { CellValue } from '../spreadsheet/CellValue';
+import { useAccounts } from '../../../hooks/useAccounts';
+import { useCategories } from '../../../hooks/useCategories';
+import { useLocalPref } from '../../../hooks/useLocalPref';
+import { useNavigate } from '../../../hooks/useNavigate';
+import { useSetThemeColor } from '../../../hooks/useSetThemeColor';
+import { SvgAdd } from '../../../icons/v1';
+import { theme, styles } from '../../../style';
+import { Button } from '../../common/Button';
+import { Text } from '../../common/Text';
+import { TextOneLine } from '../../common/TextOneLine';
+import { View } from '../../common/View';
+import { Page } from '../../Page';
+import { CellValue } from '../../spreadsheet/CellValue';
+import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs';
+import { PullToRefresh } from '../PullToRefresh';
 
 function AccountHeader({ name, amount, style = {} }) {
   return (
diff --git a/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
similarity index 96%
rename from packages/desktop-client/src/components/budget/MobileBudgetTable.jsx
rename to packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
index 0aaf75da6..92bdf7fd6 100644
--- a/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx
+++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
@@ -1,49 +1,51 @@
 import React, { memo, useRef, useState } from 'react';
+import { useDispatch } from 'react-redux';
 
 import memoizeOne from 'memoize-one';
 
+import { pushModal } from 'loot-core/client/actions';
 import { rolloverBudget, reportBudget } from 'loot-core/src/client/queries';
 import * as monthUtils from 'loot-core/src/shared/months';
 
-import { useFeatureFlag } from '../../hooks/useFeatureFlag';
-import { useLocalPref } from '../../hooks/useLocalPref';
+import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
+import { useLocalPref } from '../../../hooks/useLocalPref';
 import {
   SingleActiveEditFormProvider,
   useSingleActiveEditForm,
-} from '../../hooks/useSingleActiveEditForm';
+} from '../../../hooks/useSingleActiveEditForm';
 import {
   SvgArrowThinLeft,
   SvgArrowThinRight,
   SvgDotsHorizontalTriple,
-} from '../../icons/v1';
-import { useResponsive } from '../../ResponsiveProvider';
-import { theme, styles } from '../../style';
-import { Button } from '../common/Button';
-import { Card } from '../common/Card';
-import { Label } from '../common/Label';
-import { Menu } from '../common/Menu';
-import { Text } from '../common/Text';
-import { View } from '../common/View';
-import { MOBILE_NAV_HEIGHT } from '../mobile/MobileNavTabs';
-import { Page } from '../Page';
-import { PullToRefresh } from '../responsive/PullToRefresh';
-import { CellValue } from '../spreadsheet/CellValue';
-import { NamespaceContext } from '../spreadsheet/NamespaceContext';
-import { useFormat } from '../spreadsheet/useFormat';
-import { useSheetValue } from '../spreadsheet/useSheetValue';
-import { Tooltip, useTooltip } from '../tooltips';
-import { AmountInput } from '../util/AmountInput';
+} from '../../../icons/v1';
+import { useResponsive } from '../../../ResponsiveProvider';
+import { theme, styles } from '../../../style';
+import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover';
+import { BalanceTooltip as ReportBudgetBalanceTooltip } from '../../budget/report/BalanceTooltip';
+import { BalanceTooltip as RolloverBudgetBalanceTooltip } from '../../budget/rollover/BalanceTooltip';
+import { makeAmountGrey } from '../../budget/util';
+import { Button } from '../../common/Button';
+import { Card } from '../../common/Card';
+import { Label } from '../../common/Label';
+import { Menu } from '../../common/Menu';
+import { Text } from '../../common/Text';
+import { View } from '../../common/View';
+import { Page } from '../../Page';
+import { CellValue } from '../../spreadsheet/CellValue';
+import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
+import { useFormat } from '../../spreadsheet/useFormat';
+import { useSheetValue } from '../../spreadsheet/useSheetValue';
+import { Tooltip, useTooltip } from '../../tooltips';
+import { AmountInput } from '../../util/AmountInput';
+import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs';
+import { PullToRefresh } from '../PullToRefresh';
 // import {
 //   AmountAccessoryContext,
 //   MathOperations
 // } from '../mobile/AmountInput';
 
 // import { DragDrop, Draggable, Droppable, DragDropHighlight } from './dragdrop';
-import { BalanceWithCarryover } from './BalanceWithCarryover';
-import { ListItem, ROW_HEIGHT } from './MobileTable';
-import { BalanceTooltip as ReportBudgetBalanceTooltip } from './report/BalanceTooltip';
-import { BalanceTooltip as RolloverBudgetBalanceTooltip } from './rollover/BalanceTooltip';
-import { makeAmountGrey } from './util';
+import { ListItem, ROW_HEIGHT } from './ListItem';
 
 function ToBudget({ toBudget, onClick }) {
   const amount = useSheetValue(toBudget);
@@ -1034,7 +1036,6 @@ function BudgetGroups({
   showBudgetedCol,
   show3Cols,
   showHiddenCategories,
-  pushModal,
 }) {
   const separateGroups = memoizeOne(groups => {
     return {
@@ -1073,7 +1074,6 @@ function BudgetGroups({
                 onBudgetAction={onBudgetAction}
                 show3Cols={show3Cols}
                 showHiddenCategories={showHiddenCategories}
-                pushModal={pushModal}
               />
             );
           })}
@@ -1102,7 +1102,6 @@ function BudgetGroups({
             onEditGroup={onEditGroup}
             onEditCategory={onEditCategory}
             onBudgetAction={onBudgetAction}
-            pushModal={pushModal}
           />
         )}
       </View>
@@ -1133,7 +1132,6 @@ export function BudgetTable({
   onBudgetAction,
   onRefresh,
   onSwitchBudgetType,
-  pushModal,
   onEditGroup,
   onEditCategory,
 }) {
@@ -1142,6 +1140,7 @@ export function BudgetTable({
 
   // let editMode = false; // neuter editMode -- sorry, not rewriting drag-n-drop right now
   const format = useFormat();
+  const dispatch = useDispatch();
 
   const [showSpentColumn = false, setShowSpentColumnPref] = useLocalPref(
     'mobile.showSpentColumn',
@@ -1161,9 +1160,11 @@ export function BudgetTable({
   };
 
   const _onSwitchBudgetType = () => {
-    pushModal('switch-budget-type', {
-      onSwitch: onSwitchBudgetType,
-    });
+    dispatch(
+      pushModal('switch-budget-type', {
+        onSwitch: onSwitchBudgetType,
+      }),
+    );
   };
 
   const onToggleHiddenCategories = () => {
@@ -1378,7 +1379,6 @@ export function BudgetTable({
                 onReorderGroup={onReorderGroup}
                 onOpenMonthActionMenu={onOpenMonthActionMenu}
                 onBudgetAction={onBudgetAction}
-                pushModal={pushModal}
               />
             </View>
           ) : (
@@ -1412,7 +1412,6 @@ export function BudgetTable({
                 onReorderGroup={onReorderGroup}
                 onOpenMonthActionMenu={onOpenMonthActionMenu}
                 onBudgetAction={onBudgetAction}
-                pushModal={pushModal}
               />
             </View>
 
diff --git a/packages/desktop-client/src/components/budget/MobileTable.tsx b/packages/desktop-client/src/components/mobile/budget/ListItem.tsx
similarity index 86%
rename from packages/desktop-client/src/components/budget/MobileTable.tsx
rename to packages/desktop-client/src/components/mobile/budget/ListItem.tsx
index 1b1ad6c88..8eef9483d 100644
--- a/packages/desktop-client/src/components/budget/MobileTable.tsx
+++ b/packages/desktop-client/src/components/mobile/budget/ListItem.tsx
@@ -1,7 +1,7 @@
 import React, { type ComponentProps, type ReactNode } from 'react';
 
-import { type CSSProperties, theme } from '../../style';
-import { View } from '../common/View';
+import { type CSSProperties, theme } from '../../../style';
+import { View } from '../../common/View';
 
 export const ROW_HEIGHT = 50;
 
diff --git a/packages/desktop-client/src/components/budget/MobileBudget.tsx b/packages/desktop-client/src/components/mobile/budget/index.tsx
similarity index 65%
rename from packages/desktop-client/src/components/budget/MobileBudget.tsx
rename to packages/desktop-client/src/components/mobile/budget/index.tsx
index f5145aeec..d221f00a5 100644
--- a/packages/desktop-client/src/components/budget/MobileBudget.tsx
+++ b/packages/desktop-client/src/components/mobile/budget/index.tsx
@@ -1,6 +1,23 @@
 // @ts-strict-ignore
 import React, { useEffect, useState } from 'react';
+import { useDispatch } from 'react-redux';
 
+import {
+  applyBudgetAction,
+  collapseModals,
+  createCategory,
+  createGroup,
+  deleteCategory,
+  deleteGroup,
+  getCategories,
+  moveCategory,
+  moveCategoryGroup,
+  pushModal,
+  updateCategory,
+  updateGroup,
+  sync,
+  loadPrefs,
+} from 'loot-core/client/actions';
 import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
 import { send, listen } from 'loot-core/src/platform/client/fetch';
 import * as monthUtils from 'loot-core/src/shared/months';
@@ -9,59 +26,26 @@ import {
   type CategoryGroupEntity,
 } from 'loot-core/src/types/models';
 
-import { type BoundActions, useActions } from '../../hooks/useActions';
-import { useCategories } from '../../hooks/useCategories';
-import { useLocalPref } from '../../hooks/useLocalPref';
-import { useSetThemeColor } from '../../hooks/useSetThemeColor';
-import { AnimatedLoading } from '../../icons/AnimatedLoading';
-import { theme } from '../../style';
-import { View } from '../common/View';
-import { SyncRefresh } from '../SyncRefresh';
+import { useCategories } from '../../../hooks/useCategories';
+import { useLocalPref } from '../../../hooks/useLocalPref';
+import { useSetThemeColor } from '../../../hooks/useSetThemeColor';
+import { AnimatedLoading } from '../../../icons/AnimatedLoading';
+import { theme } from '../../../style';
+import { prewarmMonth, switchBudgetType } from '../../budget/util';
+import { View } from '../../common/View';
+import { SyncRefresh } from '../../SyncRefresh';
 
-import { BudgetTable } from './MobileBudgetTable';
-import { prewarmMonth, switchBudgetType } from './util';
+import { BudgetTable } from './BudgetTable';
 
 type BudgetInnerProps = {
   categories: CategoryEntity[];
   categoryGroups: CategoryGroupEntity[];
-  loadPrefs: BoundActions['loadPrefs'];
-  savePrefs: BoundActions['savePrefs'];
   budgetType: 'rollover' | 'report';
   spreadsheet: ReturnType<typeof useSpreadsheet>;
-  applyBudgetAction: BoundActions['applyBudgetAction'];
-  collapseModals: BoundActions['collapseModals'];
-  pushModal: BoundActions['pushModal'];
-  getCategories: BoundActions['getCategories'];
-  createCategory: BoundActions['createCategory'];
-  updateCategory: BoundActions['updateCategory'];
-  deleteCategory: BoundActions['deleteCategory'];
-  moveCategory: BoundActions['moveCategory'];
-  createGroup: BoundActions['createGroup'];
-  updateGroup: BoundActions['updateGroup'];
-  deleteGroup: BoundActions['deleteGroup'];
-  moveCategoryGroup: BoundActions['moveCategoryGroup'];
-  sync: BoundActions['sync'];
 };
 
 function BudgetInner(props: BudgetInnerProps) {
-  const {
-    categoryGroups,
-    categories,
-    loadPrefs,
-    budgetType,
-    spreadsheet,
-    applyBudgetAction,
-    collapseModals,
-    pushModal,
-    createGroup,
-    updateGroup,
-    deleteGroup,
-    moveCategoryGroup,
-    createCategory,
-    updateCategory,
-    deleteCategory,
-    moveCategory,
-  } = props;
+  const { categoryGroups, categories, budgetType, spreadsheet } = props;
 
   const currMonth = monthUtils.currentMonth();
 
@@ -73,13 +57,14 @@ function BudgetInner(props: BudgetInnerProps) {
   const [_numberFormat] = useLocalPref('numberFormat');
   const numberFormat = _numberFormat || 'comma-dot';
   const [hideFraction = false] = useLocalPref('hideFraction');
+  const dispatch = useDispatch();
 
   useEffect(() => {
     async function init() {
       const { start, end } = await send('get-budget-bounds');
       setBounds({ start, end });
 
-      await prewarmMonth(props.budgetType, props.spreadsheet, currentMonth);
+      await prewarmMonth(budgetType, spreadsheet, currentMonth);
 
       setInitialized(true);
     }
@@ -94,51 +79,59 @@ function BudgetInner(props: BudgetInnerProps) {
           tables.includes('category_groups'))
       ) {
         // TODO: is this loading every time?
-        props.getCategories();
+        dispatch(getCategories());
       }
     });
 
     return () => unlisten();
-  }, []);
+  }, [budgetType, currentMonth, dispatch, spreadsheet]);
+
+  const onBudgetAction = async (month, type, args) => {
+    dispatch(applyBudgetAction(month, type, args));
+  };
 
   const onShowBudgetSummary = () => {
     if (budgetType === 'report') {
-      pushModal('report-budget-summary', {
-        month: currentMonth,
-      });
+      dispatch(
+        pushModal('report-budget-summary', {
+          month: currentMonth,
+        }),
+      );
     } else {
-      pushModal('rollover-budget-summary', {
-        month: currentMonth,
-        onBudgetAction: applyBudgetAction,
-      });
+      dispatch(
+        pushModal('rollover-budget-summary', {
+          month: currentMonth,
+          onBudgetAction,
+        }),
+      );
     }
   };
 
-  // const onBudgetAction = type => {
-  //   applyBudgetAction(currentMonth, type, bounds);
-  // };
-
   const onAddGroup = () => {
-    pushModal('new-category-group', {
-      onValidate: name => (!name ? 'Name is required.' : null),
-      onSubmit: async name => {
-        await createGroup(name);
-      },
-    });
+    dispatch(
+      pushModal('new-category-group', {
+        onValidate: name => (!name ? 'Name is required.' : null),
+        onSubmit: async name => {
+          dispatch(createGroup(name));
+        },
+      }),
+    );
   };
 
   const onAddCategory = (groupId, isIncome) => {
-    pushModal('new-category', {
-      onValidate: name => (!name ? 'Name is required.' : null),
-      onSubmit: async name => {
-        collapseModals('category-group-menu');
-        await createCategory(name, groupId, isIncome, false);
-      },
-    });
+    dispatch(
+      pushModal('new-category', {
+        onValidate: name => (!name ? 'Name is required.' : null),
+        onSubmit: async name => {
+          dispatch(collapseModals('category-group-menu'));
+          dispatch(createCategory(name, groupId, isIncome, false));
+        },
+      }),
+    );
   };
 
   const onSaveGroup = group => {
-    updateGroup(group);
+    dispatch(updateGroup(group));
   };
 
   const onDeleteGroup = async groupId => {
@@ -157,21 +150,23 @@ function BudgetInner(props: BudgetInnerProps) {
     }
 
     if (mustTransfer) {
-      pushModal('confirm-category-delete', {
-        group: groupId,
-        onDelete: transferCategory => {
-          collapseModals('category-group-menu');
-          deleteGroup(groupId, transferCategory);
-        },
-      });
+      dispatch(
+        pushModal('confirm-category-delete', {
+          group: groupId,
+          onDelete: transferCategory => {
+            dispatch(collapseModals('category-group-menu'));
+            dispatch(deleteGroup(groupId, transferCategory));
+          },
+        }),
+      );
     } else {
-      collapseModals('category-group-menu');
-      deleteGroup(groupId);
+      dispatch(collapseModals('category-group-menu'));
+      dispatch(deleteGroup(groupId));
     }
   };
 
   const onSaveCategory = category => {
-    updateCategory(category);
+    dispatch(updateCategory(category));
   };
 
   const onDeleteCategory = async categoryId => {
@@ -180,18 +175,20 @@ function BudgetInner(props: BudgetInnerProps) {
     });
 
     if (mustTransfer) {
-      pushModal('confirm-category-delete', {
-        category: categoryId,
-        onDelete: transferCategory => {
-          if (categoryId !== transferCategory) {
-            collapseModals('category-menu');
-            deleteCategory(categoryId, transferCategory);
-          }
-        },
-      });
+      dispatch(
+        pushModal('confirm-category-delete', {
+          category: categoryId,
+          onDelete: transferCategory => {
+            if (categoryId !== transferCategory) {
+              dispatch(collapseModals('category-menu'));
+              dispatch(deleteCategory(categoryId, transferCategory));
+            }
+          },
+        }),
+      );
     } else {
-      collapseModals('category-menu');
-      deleteCategory(categoryId);
+      dispatch(collapseModals('category-menu'));
+      dispatch(deleteCategory(categoryId));
     }
   };
 
@@ -221,7 +218,7 @@ function BudgetInner(props: BudgetInnerProps) {
       targetId = catId;
     }
 
-    moveCategory(id, groupId, targetId);
+    dispatch(moveCategory(id, groupId, targetId));
   };
 
   const onReorderGroup = (id, targetId, position) => {
@@ -231,17 +228,7 @@ function BudgetInner(props: BudgetInnerProps) {
         idx < categoryGroups.length - 1 ? categoryGroups[idx + 1].id : null;
     }
 
-    moveCategoryGroup(id, targetId);
-  };
-
-  const sync = async () => {
-    const result = await props.sync();
-    if (result?.error) {
-      return 'error';
-    } else if (result) {
-      return 'updated';
-    }
-    return null;
+    dispatch(moveCategoryGroup(id, targetId));
   };
 
   const onPrevMonth = async () => {
@@ -306,7 +293,9 @@ function BudgetInner(props: BudgetInnerProps) {
       spreadsheet,
       bounds,
       currentMonth,
-      () => loadPrefs(),
+      async () => {
+        dispatch(loadPrefs());
+      },
     );
 
     setInitialized(true);
@@ -318,41 +307,49 @@ function BudgetInner(props: BudgetInnerProps) {
 
   const onEditGroupNotes = id => {
     const group = categoryGroups.find(g => g.id === id);
-    pushModal('notes', {
-      id,
-      name: group.name,
-      onSave: onSaveNotes,
-    });
+    dispatch(
+      pushModal('notes', {
+        id,
+        name: group.name,
+        onSave: onSaveNotes,
+      }),
+    );
   };
 
   const onEditCategoryNotes = id => {
     const category = categories.find(c => c.id === id);
-    pushModal('notes', {
-      id,
-      name: category.name,
-      onSave: onSaveNotes,
-    });
+    dispatch(
+      pushModal('notes', {
+        id,
+        name: category.name,
+        onSave: onSaveNotes,
+      }),
+    );
   };
 
   const onEditGroup = id => {
     const group = categoryGroups.find(g => g.id === id);
-    pushModal('category-group-menu', {
-      groupId: group.id,
-      onSave: onSaveGroup,
-      onAddCategory,
-      onEditNotes: onEditGroupNotes,
-      onDelete: onDeleteGroup,
-    });
+    dispatch(
+      pushModal('category-group-menu', {
+        groupId: group.id,
+        onSave: onSaveGroup,
+        onAddCategory,
+        onEditNotes: onEditGroupNotes,
+        onDelete: onDeleteGroup,
+      }),
+    );
   };
 
   const onEditCategory = id => {
     const category = categories.find(c => c.id === id);
-    pushModal('category-menu', {
-      categoryId: category.id,
-      onSave: onSaveCategory,
-      onEditNotes: onEditCategoryNotes,
-      onDelete: onDeleteCategory,
-    });
+    dispatch(
+      pushModal('category-menu', {
+        categoryId: category.id,
+        onSave: onSaveCategory,
+        onEditNotes: onEditCategoryNotes,
+        onDelete: onDeleteCategory,
+      }),
+    );
   };
 
   if (!categoryGroups || !initialized) {
@@ -374,7 +371,7 @@ function BudgetInner(props: BudgetInnerProps) {
   return (
     <SyncRefresh
       onSync={async () => {
-        await sync();
+        dispatch(sync());
       }}
     >
       {({ onRefresh }) => (
@@ -400,10 +397,9 @@ function BudgetInner(props: BudgetInnerProps) {
           onReorderCategory={onReorderCategory}
           onReorderGroup={onReorderGroup}
           onOpenMonthActionMenu={() => {}} //onOpenMonthActionMenu}
-          onBudgetAction={applyBudgetAction}
+          onBudgetAction={onBudgetAction}
           onRefresh={onRefresh}
           onSwitchBudgetType={onSwitchBudgetType}
-          pushModal={pushModal}
           onEditGroup={onEditGroup}
           onEditCategory={onEditCategory}
         />
@@ -416,8 +412,6 @@ export function Budget() {
   const { list: categories, grouped: categoryGroups } = useCategories();
   const [_budgetType] = useLocalPref('budgetType');
   const budgetType = _budgetType || 'rollover';
-
-  const actions = useActions();
   const spreadsheet = useSpreadsheet();
   useSetThemeColor(theme.mobileViewTheme);
   return (
@@ -425,7 +419,6 @@ export function Budget() {
       categoryGroups={categoryGroups}
       categories={categories}
       budgetType={budgetType}
-      {...actions}
       spreadsheet={spreadsheet}
     />
   );
diff --git a/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx b/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.jsx
similarity index 90%
rename from packages/desktop-client/src/components/mobile/MobileAmountInput.jsx
rename to packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.jsx
index 0bc83c5bf..6aa309dd3 100644
--- a/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx
+++ b/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.jsx
@@ -6,11 +6,11 @@ import {
   getNumberFormat,
 } from 'loot-core/src/shared/util';
 
-import { useMergedRefs } from '../../hooks/useMergedRefs';
-import { theme } from '../../style';
-import { Button } from '../common/Button';
-import { Text } from '../common/Text';
-import { View } from '../common/View';
+import { useMergedRefs } from '../../../hooks/useMergedRefs';
+import { theme } from '../../../style';
+import { Button } from '../../common/Button';
+import { Text } from '../../common/Text';
+import { View } from '../../common/View';
 
 const AmountInput = memo(function AmountInput({
   focused,
@@ -24,25 +24,19 @@ const AmountInput = memo(function AmountInput({
   const inputRef = useRef();
   const mergedInputRef = useMergedRefs(props.inputRef, inputRef);
 
-  const getInitialValue = () => Math.abs(props.value);
+  const initialValue = Math.abs(props.value);
 
   useEffect(() => {
     if (focused) {
-      focus();
-    }
-  }, []);
-
-  useEffect(() => {
-    if (focused) {
-      focus();
+      inputRef.current?.focus();
     }
   }, [focused]);
 
   useEffect(() => {
     setEditing(false);
     setText('');
-    setValue(getInitialValue());
-  }, [props.value]);
+    setValue(initialValue);
+  }, [initialValue]);
 
   const parseText = () => {
     return toRelaxedNumber(text.replace(/[,.]/, getNumberFormat().separator));
@@ -53,12 +47,6 @@ const AmountInput = memo(function AmountInput({
       setEditing(true);
     }
   };
-
-  const focus = () => {
-    inputRef.current?.focus();
-    setValue(getInitialValue());
-  };
-
   const applyText = () => {
     const parsed = parseText();
     const newValue = editing ? parsed : value;
@@ -147,7 +135,7 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({
     } else if (value > 0 || (zeroSign !== '-' && value === 0)) {
       setIsNegative(false);
     }
-  }, []);
+  }, [sign, value, zeroSign]);
 
   const toggleIsNegative = () => {
     setIsNegative(!isNegative);
diff --git a/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx b/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx
new file mode 100644
index 000000000..9c2e0e274
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx
@@ -0,0 +1,53 @@
+import React, { useEffect, useRef } from 'react';
+
+import { useListBox } from '@react-aria/listbox';
+import { useListState } from '@react-stately/list';
+
+import { ListBoxSection } from './ListBoxSection';
+
+export function ListBox(props) {
+  const state = useListState(props);
+  const listBoxRef = useRef();
+  const { listBoxProps, labelProps } = useListBox(props, state, listBoxRef);
+  const { loadMore } = props;
+
+  useEffect(() => {
+    function loadMoreTransactions() {
+      if (
+        Math.abs(
+          listBoxRef.current.scrollHeight -
+            listBoxRef.current.clientHeight -
+            listBoxRef.current.scrollTop,
+        ) < listBoxRef.current.clientHeight // load more when we're one screen height from the end
+      ) {
+        loadMore?.();
+      }
+    }
+    const currentListBoxRef = listBoxRef.current;
+    currentListBoxRef?.addEventListener('scroll', loadMoreTransactions);
+
+    return () => {
+      currentListBoxRef?.removeEventListener('scroll', loadMoreTransactions);
+    };
+  }, [loadMore, state.collection]);
+
+  return (
+    <>
+      <div {...labelProps}>{props.label}</div>
+      <ul
+        {...listBoxProps}
+        ref={listBoxRef}
+        style={{
+          padding: 0,
+          listStyle: 'none',
+          margin: 0,
+          width: '100%',
+        }}
+      >
+        {[...state.collection].map(item => (
+          <ListBoxSection key={item.key} section={item} state={state} />
+        ))}
+      </ul>
+    </>
+  );
+}
diff --git a/packages/desktop-client/src/components/mobile/transactions/ListBoxSection.jsx b/packages/desktop-client/src/components/mobile/transactions/ListBoxSection.jsx
new file mode 100644
index 000000000..b12dc3e41
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/transactions/ListBoxSection.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+
+import { useListBoxSection } from '@react-aria/listbox';
+import { css } from 'glamor';
+
+import { styles, theme } from '../../../style';
+
+import { Option } from './Option';
+
+const zIndices = { SECTION_HEADING: 10 };
+
+export function ListBoxSection({ section, state }) {
+  const { itemProps, headingProps, groupProps } = useListBoxSection({
+    heading: section.rendered,
+    'aria-label': section['aria-label'],
+  });
+
+  // The heading is rendered inside an <li> element, which contains
+  // a <ul> with the child items.
+  return (
+    <li {...itemProps} style={{ width: '100%' }}>
+      {section.rendered && (
+        <div
+          {...headingProps}
+          className={`${css(styles.smallText, {
+            backgroundColor: theme.pageBackground,
+            borderBottom: `1px solid ${theme.tableBorder}`,
+            borderTop: `1px solid ${theme.tableBorder}`,
+            color: theme.tableHeaderText,
+            display: 'flex',
+            justifyContent: 'center',
+            paddingBottom: 4,
+            paddingTop: 4,
+            position: 'sticky',
+            top: '0',
+            width: '100%',
+            zIndex: zIndices.SECTION_HEADING,
+          })}`}
+        >
+          {section.rendered}
+        </div>
+      )}
+      <ul
+        {...groupProps}
+        style={{
+          padding: 0,
+          listStyle: 'none',
+        }}
+      >
+        {[...section.childNodes].map((node, index, nodes) => (
+          <Option
+            key={node.key}
+            item={node}
+            state={state}
+            isLast={index === nodes.length - 1}
+          />
+        ))}
+      </ul>
+    </li>
+  );
+}
diff --git a/packages/desktop-client/src/components/mobile/transactions/Option.jsx b/packages/desktop-client/src/components/mobile/transactions/Option.jsx
new file mode 100644
index 000000000..8fc46836e
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/transactions/Option.jsx
@@ -0,0 +1,33 @@
+import React, { useRef } from 'react';
+
+import { useFocusRing } from '@react-aria/focus';
+import { useOption } from '@react-aria/listbox';
+import { mergeProps } from '@react-aria/utils';
+
+import { theme } from '../../../style';
+
+export function Option({ isLast, item, state }) {
+  // Get props for the option element
+  const ref = useRef();
+  const { optionProps, isSelected } = useOption({ key: item.key }, state, ref);
+
+  // Determine whether we should show a keyboard
+  const { isFocusVisible, focusProps } = useFocusRing();
+
+  return (
+    <li
+      {...mergeProps(optionProps, focusProps)}
+      ref={ref}
+      style={{
+        background: isSelected
+          ? theme.tableRowBackgroundHighlight
+          : theme.tableBackground,
+        color: isSelected ? theme.mobileModalText : null,
+        outline: isFocusVisible ? '2px solid orange' : 'none',
+        ...(!isLast && { borderBottom: `1px solid ${theme.tableBorder}` }),
+      }}
+    >
+      {item.rendered}
+    </li>
+  );
+}
diff --git a/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx
new file mode 100644
index 000000000..d6d2ceced
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx
@@ -0,0 +1,209 @@
+import React, { memo, useMemo } from 'react';
+
+import { getScheduledAmount } from 'loot-core/src/shared/schedules';
+import { isPreviewId } from 'loot-core/src/shared/transactions';
+import { integerToCurrency, groupById } from 'loot-core/src/shared/util';
+
+import {
+  SvgArrowsSynchronize,
+  SvgCheckCircle1,
+  SvgLockClosed,
+} from '../../../icons/v2';
+import { styles, theme } from '../../../style';
+import { Button } from '../../common/Button';
+import { Text } from '../../common/Text';
+import { TextOneLine } from '../../common/TextOneLine';
+import { View } from '../../common/View';
+
+import { lookupName, getDescriptionPretty, Status } from './TransactionEdit';
+
+const ROW_HEIGHT = 50;
+
+const ListItem = ({ children, style, ...props }) => {
+  return (
+    <View
+      style={{
+        height: ROW_HEIGHT,
+        flexDirection: 'row',
+        alignItems: 'center',
+        paddingLeft: 10,
+        paddingRight: 10,
+        ...style,
+      }}
+      {...props}
+    >
+      {children}
+    </View>
+  );
+};
+
+ListItem.displayName = 'ListItem';
+
+export const Transaction = memo(function Transaction({
+  transaction,
+  account,
+  accounts,
+  categories,
+  payees,
+  added,
+  onSelect,
+  style,
+}) {
+  const accountsById = useMemo(() => groupById(accounts), [accounts]);
+  const payeesById = useMemo(() => groupById(payees), [payees]);
+
+  const {
+    id,
+    payee: payeeId,
+    amount: originalAmount,
+    category: categoryId,
+    cleared,
+    is_parent: isParent,
+    notes,
+    schedule,
+  } = transaction;
+
+  let amount = originalAmount;
+  if (isPreviewId(id)) {
+    amount = getScheduledAmount(amount);
+  }
+
+  const categoryName = lookupName(categories, categoryId);
+
+  const payee = payeesById && payeeId && payeesById[payeeId];
+  const transferAcct =
+    payee && payee.transfer_acct && accountsById[payee.transfer_acct];
+
+  const prettyDescription = getDescriptionPretty(
+    transaction,
+    payee,
+    transferAcct,
+  );
+  const specialCategory = account?.offbudget
+    ? 'Off Budget'
+    : transferAcct
+      ? 'Transfer'
+      : isParent
+        ? 'Split'
+        : null;
+
+  const prettyCategory = specialCategory || categoryName;
+
+  const isPreview = isPreviewId(id);
+  const isReconciled = transaction.reconciled;
+  const textStyle = isPreview && {
+    fontStyle: 'italic',
+    color: theme.pageTextLight,
+  };
+
+  return (
+    <Button
+      onClick={() => onSelect(transaction)}
+      style={{
+        backgroundColor: theme.tableBackground,
+        border: 'none',
+        width: '100%',
+      }}
+    >
+      <ListItem
+        style={{
+          flex: 1,
+          height: 60,
+          padding: '5px 10px', // remove padding when Button is back
+          ...(isPreview && {
+            backgroundColor: theme.tableRowHeaderBackground,
+          }),
+          ...style,
+        }}
+      >
+        <View style={{ flex: 1 }}>
+          <View style={{ flexDirection: 'row', alignItems: 'center' }}>
+            {schedule && (
+              <SvgArrowsSynchronize
+                style={{
+                  width: 12,
+                  height: 12,
+                  marginRight: 5,
+                  color: textStyle.color || theme.menuItemText,
+                }}
+              />
+            )}
+            <TextOneLine
+              style={{
+                ...styles.text,
+                ...textStyle,
+                fontSize: 14,
+                fontWeight: added ? '600' : '400',
+                ...(prettyDescription === '' && {
+                  color: theme.tableTextLight,
+                  fontStyle: 'italic',
+                }),
+              }}
+            >
+              {prettyDescription || 'Empty'}
+            </TextOneLine>
+          </View>
+          {isPreview ? (
+            <Status status={notes} />
+          ) : (
+            <View
+              style={{
+                flexDirection: 'row',
+                alignItems: 'center',
+                marginTop: 3,
+              }}
+            >
+              {isReconciled ? (
+                <SvgLockClosed
+                  style={{
+                    width: 11,
+                    height: 11,
+                    color: theme.noticeTextLight,
+                    marginRight: 5,
+                  }}
+                />
+              ) : (
+                <SvgCheckCircle1
+                  style={{
+                    width: 11,
+                    height: 11,
+                    color: cleared
+                      ? theme.noticeTextLight
+                      : theme.pageTextSubdued,
+                    marginRight: 5,
+                  }}
+                />
+              )}
+              <TextOneLine
+                style={{
+                  fontSize: 11,
+                  marginTop: 1,
+                  fontWeight: '400',
+                  color: prettyCategory
+                    ? theme.tableText
+                    : theme.menuItemTextSelected,
+                  fontStyle:
+                    specialCategory || !prettyCategory ? 'italic' : undefined,
+                  textAlign: 'left',
+                }}
+              >
+                {prettyCategory || 'Uncategorized'}
+              </TextOneLine>
+            </View>
+          )}
+        </View>
+        <Text
+          style={{
+            ...styles.text,
+            ...textStyle,
+            marginLeft: 25,
+            marginRight: 5,
+            fontSize: 14,
+          }}
+        >
+          {integerToCurrency(amount)}
+        </Text>
+      </ListItem>
+    </Button>
+  );
+});
diff --git a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx
similarity index 67%
rename from packages/desktop-client/src/components/transactions/MobileTransaction.jsx
rename to packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx
index 24fc2987b..9e9be907a 100644
--- a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx
+++ b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx
@@ -6,29 +6,22 @@ import React, {
   memo,
   useMemo,
 } from 'react';
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
 import { useParams } from 'react-router-dom';
 
-import { useFocusRing } from '@react-aria/focus';
-import { useListBox, useListBoxSection, useOption } from '@react-aria/listbox';
-import { mergeProps } from '@react-aria/utils';
-import { Item, Section } from '@react-stately/collections';
-import { useListState } from '@react-stately/list';
 import {
   format as formatDate,
   parse as parseDate,
   parseISO,
   isValid as isValidDate,
 } from 'date-fns';
-import { css } from 'glamor';
 
+import { pushModal, setLastTransaction } from 'loot-core/client/actions';
 import { runQuery } from 'loot-core/src/client/query-helpers';
 import { send } from 'loot-core/src/platform/client/fetch';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { q } from 'loot-core/src/shared/query';
-import { getScheduledAmount } from 'loot-core/src/shared/schedules';
 import {
-  isPreviewId,
   ungroupTransactions,
   updateTransaction,
   realizeTempTransactions,
@@ -46,48 +39,35 @@ import {
   groupById,
 } from 'loot-core/src/shared/util';
 
-import { useAccounts } from '../../hooks/useAccounts';
-import { useActions } from '../../hooks/useActions';
-import { useCategories } from '../../hooks/useCategories';
-import { useDateFormat } from '../../hooks/useDateFormat';
-import { useNavigate } from '../../hooks/useNavigate';
-import { usePayees } from '../../hooks/usePayees';
-import { useSetThemeColor } from '../../hooks/useSetThemeColor';
+import { useAccounts } from '../../../hooks/useAccounts';
+import { useCategories } from '../../../hooks/useCategories';
+import { useDateFormat } from '../../../hooks/useDateFormat';
+import { useNavigate } from '../../../hooks/useNavigate';
+import { usePayees } from '../../../hooks/usePayees';
+import { useSetThemeColor } from '../../../hooks/useSetThemeColor';
 import {
   SingleActiveEditFormProvider,
   useSingleActiveEditForm,
-} from '../../hooks/useSingleActiveEditForm';
-import { SvgSplit } from '../../icons/v0';
-import { SvgAdd, SvgTrash } from '../../icons/v1';
-import {
-  SvgArrowsSynchronize,
-  SvgCheckCircle1,
-  SvgLockClosed,
-  SvgPencilWriteAlternate,
-} from '../../icons/v2';
-import { styles, theme } from '../../style';
-import { Button } from '../common/Button';
-import { Text } from '../common/Text';
-import { TextOneLine } from '../common/TextOneLine';
-import { View } from '../common/View';
-import { FocusableAmountInput } from '../mobile/MobileAmountInput';
-import {
-  FieldLabel,
-  TapField,
-  InputField,
-  BooleanField,
-} from '../mobile/MobileForms';
-import { MobileBackButton } from '../MobileBackButton';
-import { Page } from '../Page';
-import { AmountInput } from '../util/AmountInput';
-
-const zIndices = { SECTION_HEADING: 10 };
+} from '../../../hooks/useSingleActiveEditForm';
+import { SvgSplit } from '../../../icons/v0';
+import { SvgAdd, SvgTrash } from '../../../icons/v1';
+import { SvgPencilWriteAlternate } from '../../../icons/v2';
+import { styles, theme } from '../../../style';
+import { Button } from '../../common/Button';
+import { Text } from '../../common/Text';
+import { View } from '../../common/View';
+import { MobileBackButton } from '../../MobileBackButton';
+import { Page } from '../../Page';
+import { AmountInput } from '../../util/AmountInput';
+import { FieldLabel, TapField, InputField, BooleanField } from '../MobileForms';
+
+import { FocusableAmountInput } from './FocusableAmountInput';
 
 function getFieldName(transactionId, field) {
   return `${field}-${transactionId}`;
 }
 
-function getDescriptionPretty(transaction, payee, transferAcct) {
+export function getDescriptionPretty(transaction, payee, transferAcct) {
   const { amount } = transaction;
 
   if (transferAcct) {
@@ -144,14 +124,14 @@ function deserializeTransaction(transaction, originalTransaction, dateFormat) {
   return { ...realTransaction, date, amount: amountToInteger(amount || 0) };
 }
 
-function lookupName(items, id) {
+export function lookupName(items, id) {
   if (!id) {
     return null;
   }
   return items.find(item => item.id === id)?.name;
 }
 
-function Status({ status }) {
+export function Status({ status }) {
   let color;
 
   switch (status) {
@@ -439,9 +419,18 @@ const TransactionEditInner = memo(function TransactionEditInner({
   dateFormat,
   transactions: unserializedTransactions,
   navigate,
-  pushModal,
   ...props
 }) {
+  const dispatch = useDispatch();
+  const transactions = useMemo(
+    () =>
+      unserializedTransactions.map(t => serializeTransaction(t, dateFormat)) ||
+      [],
+    [unserializedTransactions, dateFormat],
+  );
+
+  const [transaction, ...childTransactions] = transactions;
+
   const { editingField, onRequestActiveEdit, onClearActiveEdit } =
     useSingleActiveEditForm();
   const [totalAmountFocused, setTotalAmountFocused] = useState(false);
@@ -450,6 +439,20 @@ const TransactionEditInner = memo(function TransactionEditInner({
   const payeesById = useMemo(() => groupById(payees), [payees]);
   const accountsById = useMemo(() => groupById(accounts), [accounts]);
 
+  const onTotalAmountEdit = () => {
+    onRequestActiveEdit?.(getFieldName(transaction.id, 'amount'), () => {
+      setTotalAmountFocused(true);
+      return () => setTotalAmountFocused(false);
+    });
+  };
+
+  useEffect(() => {
+    if (adding) {
+      onTotalAmountEdit();
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
   const getAccount = trans => {
     return trans?.account && accountsById?.[trans.account];
   };
@@ -482,19 +485,6 @@ const TransactionEditInner = memo(function TransactionEditInner({
         : lookupName(categories, trans.category);
   };
 
-  const onTotalAmountEdit = () => {
-    onRequestActiveEdit?.(getFieldName(transaction.id, 'amount'), () => {
-      setTotalAmountFocused(true);
-      return () => setTotalAmountFocused(false);
-    });
-  };
-
-  useEffect(() => {
-    if (adding) {
-      onTotalAmountEdit();
-    }
-  }, []);
-
   const onTotalAmountUpdate = value => {
     if (transaction.amount !== value) {
       onEdit(transaction, 'amount', value.toString());
@@ -530,10 +520,12 @@ const TransactionEditInner = memo(function TransactionEditInner({
       // On the web only certain changes trigger a warning.
       // Should we bring that here as well? Or does the nature of the editing form
       // make this more appropriate?
-      pushModal('confirm-transaction-edit', {
-        onConfirm: onConfirmSave,
-        confirmReason: 'editReconciled',
-      });
+      dispatch(
+        pushModal('confirm-transaction-edit', {
+          onConfirm: onConfirmSave,
+          confirmReason: 'editReconciled',
+        }),
+      );
     } else {
       onConfirmSave();
     }
@@ -551,21 +543,23 @@ const TransactionEditInner = memo(function TransactionEditInner({
 
   const onClick = (transactionId, name) => {
     onRequestActiveEdit?.(getFieldName(transaction.id, 'payee'), () => {
-      pushModal('edit-field', {
-        name,
-        onSubmit: (name, value) => {
-          const transaction = unserializedTransactions.find(
-            t => t.id === transactionId,
-          );
-          // This is a deficiency of this API, need to fix. It
-          // assumes that it receives a serialized transaction,
-          // but we only have access to the raw transaction
-          onEdit(serializeTransaction(transaction, dateFormat), name, value);
-        },
-        onClose: () => {
-          onClearActiveEdit();
-        },
-      });
+      dispatch(
+        pushModal('edit-field', {
+          name,
+          onSubmit: (name, value) => {
+            const transaction = unserializedTransactions.find(
+              t => t.id === transactionId,
+            );
+            // This is a deficiency of this API, need to fix. It
+            // assumes that it receives a serialized transaction,
+            // but we only have access to the raw transaction
+            onEdit(serializeTransaction(transaction, dateFormat), name, value);
+          },
+          onClose: () => {
+            onClearActiveEdit();
+          },
+        }),
+      );
     });
   };
 
@@ -590,10 +584,12 @@ const TransactionEditInner = memo(function TransactionEditInner({
     };
 
     if (transaction.reconciled) {
-      pushModal('confirm-transaction-edit', {
-        onConfirm: onConfirmDelete,
-        confirmReason: 'deleteReconciled',
-      });
+      dispatch(
+        pushModal('confirm-transaction-edit', {
+          onConfirm: onConfirmDelete,
+          confirmReason: 'deleteReconciled',
+        }),
+      );
     } else {
       onConfirmDelete();
     }
@@ -619,15 +615,6 @@ const TransactionEditInner = memo(function TransactionEditInner({
     scrollChildTransactionIntoView(id);
   };
 
-  const transactions = useMemo(
-    () =>
-      unserializedTransactions.map(t => serializeTransaction(t, dateFormat)) ||
-      [],
-    [unserializedTransactions, dateFormat],
-  );
-
-  const [transaction, ...childTransactions] = transactions;
-
   useEffect(() => {
     const noAmountTransaction = childTransactions.find(t => t.amount === 0);
     if (noAmountTransaction) {
@@ -937,6 +924,7 @@ function TransactionEditUnconnected(props) {
   const { categories, accounts, payees, lastTransaction, dateFormat } = props;
   const { id: accountId, transactionId } = useParams();
   const navigate = useNavigate();
+  const dispatch = useDispatch();
   const [transactions, setTransactions] = useState([]);
   const [fetchedTransactions, setFetchedTransactions] = useState([]);
   const adding = useRef(false);
@@ -960,7 +948,9 @@ function TransactionEditUnconnected(props) {
           .select('*')
           .options({ splits: 'grouped' }),
       );
-      setFetchedTransactions(ungroupTransactions(data));
+      const fetchedTransactions = ungroupTransactions(data);
+      setTransactions(fetchedTransactions);
+      setFetchedTransactions(fetchedTransactions);
     }
     if (transactionId) {
       fetchTransaction();
@@ -969,10 +959,6 @@ function TransactionEditUnconnected(props) {
     }
   }, [transactionId]);
 
-  useEffect(() => {
-    setTransactions(fetchedTransactions);
-  }, [fetchedTransactions]);
-
   useEffect(() => {
     if (adding.current) {
       setTransactions(
@@ -982,7 +968,7 @@ function TransactionEditUnconnected(props) {
         ),
       );
     }
-  }, [adding.current, accountId, lastTransaction]);
+  }, [accountId, lastTransaction]);
 
   if (
     categories.length === 0 ||
@@ -1049,7 +1035,7 @@ function TransactionEditUnconnected(props) {
     if (adding.current) {
       // The first one is always the "parent" and the only one we care
       // about
-      props.setLastTransaction(newTransactions[0]);
+      dispatch(setLastTransaction(newTransactions[0]));
     }
   };
 
@@ -1095,7 +1081,6 @@ function TransactionEditUnconnected(props) {
         categories={categories}
         accounts={accounts}
         payees={payees}
-        pushModal={props.pushModal}
         navigate={navigate}
         dateFormat={dateFormat}
         onEdit={onEdit}
@@ -1114,13 +1099,11 @@ export const TransactionEdit = props => {
   const lastTransaction = useSelector(state => state.queries.lastTransaction);
   const accounts = useAccounts();
   const dateFormat = useDateFormat() || 'MM/dd/yyyy';
-  const actions = useActions();
 
   return (
     <SingleActiveEditFormProvider formName="mobile-transaction">
       <TransactionEditUnconnected
         {...props}
-        {...actions}
         categories={categories}
         payees={payees}
         lastTransaction={lastTransaction}
@@ -1130,425 +1113,3 @@ export const TransactionEdit = props => {
     </SingleActiveEditFormProvider>
   );
 };
-
-const Transaction = memo(function Transaction({
-  transaction,
-  account,
-  accounts,
-  categories,
-  payees,
-  added,
-  onSelect,
-  style,
-}) {
-  const accountsById = useMemo(() => groupById(accounts), [accounts]);
-  const payeesById = useMemo(() => groupById(payees), [payees]);
-
-  const {
-    id,
-    payee: payeeId,
-    amount: originalAmount,
-    category: categoryId,
-    cleared,
-    is_parent: isParent,
-    notes,
-    schedule,
-  } = transaction;
-
-  let amount = originalAmount;
-  if (isPreviewId(id)) {
-    amount = getScheduledAmount(amount);
-  }
-
-  const categoryName = lookupName(categories, categoryId);
-
-  const payee = payeesById && payeeId && payeesById[payeeId];
-  const transferAcct =
-    payee && payee.transfer_acct && accountsById[payee.transfer_acct];
-
-  const prettyDescription = getDescriptionPretty(
-    transaction,
-    payee,
-    transferAcct,
-  );
-  const specialCategory = account?.offbudget
-    ? 'Off Budget'
-    : transferAcct
-      ? 'Transfer'
-      : isParent
-        ? 'Split'
-        : null;
-
-  const prettyCategory = specialCategory || categoryName;
-
-  const isPreview = isPreviewId(id);
-  const isReconciled = transaction.reconciled;
-  const textStyle = isPreview && {
-    fontStyle: 'italic',
-    color: theme.pageTextLight,
-  };
-
-  return (
-    <Button
-      onClick={() => onSelect(transaction)}
-      style={{
-        backgroundColor: theme.tableBackground,
-        border: 'none',
-        width: '100%',
-      }}
-    >
-      <ListItem
-        style={{
-          flex: 1,
-          height: 60,
-          padding: '5px 10px', // remove padding when Button is back
-          ...(isPreview && {
-            backgroundColor: theme.tableRowHeaderBackground,
-          }),
-          ...style,
-        }}
-      >
-        <View style={{ flex: 1 }}>
-          <View style={{ flexDirection: 'row', alignItems: 'center' }}>
-            {schedule && (
-              <SvgArrowsSynchronize
-                style={{
-                  width: 12,
-                  height: 12,
-                  marginRight: 5,
-                  color: textStyle.color || theme.menuItemText,
-                }}
-              />
-            )}
-            <TextOneLine
-              style={{
-                ...styles.text,
-                ...textStyle,
-                fontSize: 14,
-                fontWeight: added ? '600' : '400',
-                ...(prettyDescription === '' && {
-                  color: theme.tableTextLight,
-                  fontStyle: 'italic',
-                }),
-              }}
-            >
-              {prettyDescription || 'Empty'}
-            </TextOneLine>
-          </View>
-          {isPreview ? (
-            <Status status={notes} />
-          ) : (
-            <View
-              style={{
-                flexDirection: 'row',
-                alignItems: 'center',
-                marginTop: 3,
-              }}
-            >
-              {isReconciled ? (
-                <SvgLockClosed
-                  style={{
-                    width: 11,
-                    height: 11,
-                    color: theme.noticeTextLight,
-                    marginRight: 5,
-                  }}
-                />
-              ) : (
-                <SvgCheckCircle1
-                  style={{
-                    width: 11,
-                    height: 11,
-                    color: cleared
-                      ? theme.noticeTextLight
-                      : theme.pageTextSubdued,
-                    marginRight: 5,
-                  }}
-                />
-              )}
-              <TextOneLine
-                style={{
-                  fontSize: 11,
-                  marginTop: 1,
-                  fontWeight: '400',
-                  color: prettyCategory
-                    ? theme.tableText
-                    : theme.menuItemTextSelected,
-                  fontStyle:
-                    specialCategory || !prettyCategory ? 'italic' : undefined,
-                  textAlign: 'left',
-                }}
-              >
-                {prettyCategory || 'Uncategorized'}
-              </TextOneLine>
-            </View>
-          )}
-        </View>
-        <Text
-          style={{
-            ...styles.text,
-            ...textStyle,
-            marginLeft: 25,
-            marginRight: 5,
-            fontSize: 14,
-          }}
-        >
-          {integerToCurrency(amount)}
-        </Text>
-      </ListItem>
-    </Button>
-  );
-});
-
-export function TransactionList({
-  account,
-  accounts,
-  categories,
-  payees,
-  transactions,
-  isNew,
-  onSelect,
-  scrollProps = {},
-  onLoadMore,
-}) {
-  const sections = useMemo(() => {
-    // Group by date. We can assume transactions is ordered
-    const sections = [];
-    transactions.forEach(transaction => {
-      if (
-        sections.length === 0 ||
-        transaction.date !== sections[sections.length - 1].date
-      ) {
-        // Mark the last transaction in the section so it can render
-        // with a different border
-        const lastSection = sections[sections.length - 1];
-        if (lastSection && lastSection.data.length > 0) {
-          const lastData = lastSection.data;
-          lastData[lastData.length - 1].isLast = true;
-        }
-
-        sections.push({
-          id: `${isPreviewId(transaction.id) ? 'preview/' : ''}${
-            transaction.date
-          }`,
-          date: transaction.date,
-          data: [],
-        });
-      }
-
-      if (!transaction.is_child) {
-        sections[sections.length - 1].data.push(transaction);
-      }
-    });
-    return sections;
-  }, [transactions]);
-
-  return (
-    <>
-      {scrollProps.ListHeaderComponent}
-      <ListBox
-        {...scrollProps}
-        aria-label="transaction list"
-        label=""
-        loadMore={onLoadMore}
-        selectionMode="none"
-      >
-        {sections.length === 0 ? (
-          <Section>
-            <Item textValue="No transactions">
-              <div
-                style={{
-                  display: 'flex',
-                  justifyContent: 'center',
-                  width: '100%',
-                  backgroundColor: theme.mobilePageBackground,
-                }}
-              >
-                <Text style={{ fontSize: 15 }}>No transactions</Text>
-              </div>
-            </Item>
-          </Section>
-        ) : null}
-        {sections.map(section => {
-          return (
-            <Section
-              title={
-                <span>{monthUtils.format(section.date, 'MMMM dd, yyyy')}</span>
-              }
-              key={section.id}
-            >
-              {section.data.map((transaction, index, transactions) => {
-                return (
-                  <Item
-                    key={transaction.id}
-                    style={{
-                      fontSize:
-                        index === transactions.length - 1 ? 98 : 'inherit',
-                    }}
-                    textValue={transaction.id}
-                  >
-                    <Transaction
-                      transaction={transaction}
-                      account={account}
-                      categories={categories}
-                      accounts={accounts}
-                      payees={payees}
-                      added={isNew(transaction.id)}
-                      onSelect={onSelect} // onSelect(transaction)}
-                    />
-                  </Item>
-                );
-              })}
-            </Section>
-          );
-        })}
-      </ListBox>
-    </>
-  );
-}
-
-function ListBox(props) {
-  const state = useListState(props);
-  const listBoxRef = useRef();
-  const { listBoxProps, labelProps } = useListBox(props, state, listBoxRef);
-
-  useEffect(() => {
-    function loadMoreTransactions() {
-      if (
-        Math.abs(
-          listBoxRef.current.scrollHeight -
-            listBoxRef.current.clientHeight -
-            listBoxRef.current.scrollTop,
-        ) < listBoxRef.current.clientHeight // load more when we're one screen height from the end
-      ) {
-        props.loadMore();
-      }
-    }
-
-    listBoxRef.current.addEventListener('scroll', loadMoreTransactions);
-
-    return () => {
-      listBoxRef.current?.removeEventListener('scroll', loadMoreTransactions);
-    };
-  }, [state.collection]);
-
-  return (
-    <>
-      <div {...labelProps}>{props.label}</div>
-      <ul
-        {...listBoxProps}
-        ref={listBoxRef}
-        style={{
-          padding: 0,
-          listStyle: 'none',
-          margin: 0,
-          width: '100%',
-        }}
-      >
-        {[...state.collection].map(item => (
-          <ListBoxSection key={item.key} section={item} state={state} />
-        ))}
-      </ul>
-    </>
-  );
-}
-
-function ListBoxSection({ section, state }) {
-  const { itemProps, headingProps, groupProps } = useListBoxSection({
-    heading: section.rendered,
-    'aria-label': section['aria-label'],
-  });
-
-  // The heading is rendered inside an <li> element, which contains
-  // a <ul> with the child items.
-  return (
-    <li {...itemProps} style={{ width: '100%' }}>
-      {section.rendered && (
-        <div
-          {...headingProps}
-          className={`${css(styles.smallText, {
-            backgroundColor: theme.pageBackground,
-            borderBottom: `1px solid ${theme.tableBorder}`,
-            borderTop: `1px solid ${theme.tableBorder}`,
-            color: theme.tableHeaderText,
-            display: 'flex',
-            justifyContent: 'center',
-            paddingBottom: 4,
-            paddingTop: 4,
-            position: 'sticky',
-            top: '0',
-            width: '100%',
-            zIndex: zIndices.SECTION_HEADING,
-          })}`}
-        >
-          {section.rendered}
-        </div>
-      )}
-      <ul
-        {...groupProps}
-        style={{
-          padding: 0,
-          listStyle: 'none',
-        }}
-      >
-        {[...section.childNodes].map((node, index, nodes) => (
-          <Option
-            key={node.key}
-            item={node}
-            state={state}
-            isLast={index === nodes.length - 1}
-          />
-        ))}
-      </ul>
-    </li>
-  );
-}
-
-function Option({ isLast, item, state }) {
-  // Get props for the option element
-  const ref = useRef();
-  const { optionProps, isSelected } = useOption({ key: item.key }, state, ref);
-
-  // Determine whether we should show a keyboard
-  const { isFocusVisible, focusProps } = useFocusRing();
-
-  return (
-    <li
-      {...mergeProps(optionProps, focusProps)}
-      ref={ref}
-      style={{
-        background: isSelected
-          ? theme.tableRowBackgroundHighlight
-          : theme.tableBackground,
-        color: isSelected ? theme.mobileModalText : null,
-        outline: isFocusVisible ? '2px solid orange' : 'none',
-        ...(!isLast && { borderBottom: `1px solid ${theme.tableBorder}` }),
-      }}
-    >
-      {item.rendered}
-    </li>
-  );
-}
-
-const ROW_HEIGHT = 50;
-
-const ListItem = forwardRef(({ children, style, ...props }, ref) => {
-  return (
-    <View
-      style={{
-        height: ROW_HEIGHT,
-        flexDirection: 'row',
-        alignItems: 'center',
-        paddingLeft: 10,
-        paddingRight: 10,
-        ...style,
-      }}
-      ref={ref}
-      {...props}
-    >
-      {children}
-    </View>
-  );
-});
-
-ListItem.displayName = 'ListItem';
diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx
new file mode 100644
index 000000000..e76fe396d
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx
@@ -0,0 +1,117 @@
+import React, { useMemo } from 'react';
+
+import { Item, Section } from '@react-stately/collections';
+
+import * as monthUtils from 'loot-core/src/shared/months';
+import { isPreviewId } from 'loot-core/src/shared/transactions';
+
+import { theme } from '../../../style';
+import { Text } from '../../common/Text';
+
+import { ListBox } from './ListBox';
+import { Transaction } from './Transaction';
+
+export function TransactionList({
+  account,
+  accounts,
+  categories,
+  payees,
+  transactions,
+  isNew,
+  onSelect,
+  scrollProps = {},
+  onLoadMore,
+}) {
+  const sections = useMemo(() => {
+    // Group by date. We can assume transactions is ordered
+    const sections = [];
+    transactions.forEach(transaction => {
+      if (
+        sections.length === 0 ||
+        transaction.date !== sections[sections.length - 1].date
+      ) {
+        // Mark the last transaction in the section so it can render
+        // with a different border
+        const lastSection = sections[sections.length - 1];
+        if (lastSection && lastSection.data.length > 0) {
+          const lastData = lastSection.data;
+          lastData[lastData.length - 1].isLast = true;
+        }
+
+        sections.push({
+          id: `${isPreviewId(transaction.id) ? 'preview/' : ''}${transaction.date}`,
+          date: transaction.date,
+          data: [],
+        });
+      }
+
+      if (!transaction.is_child) {
+        sections[sections.length - 1].data.push(transaction);
+      }
+    });
+    return sections;
+  }, [transactions]);
+
+  return (
+    <>
+      {scrollProps.ListHeaderComponent}
+      <ListBox
+        {...scrollProps}
+        aria-label="transaction list"
+        label=""
+        loadMore={onLoadMore}
+        selectionMode="none"
+      >
+        {sections.length === 0 ? (
+          <Section>
+            <Item textValue="No transactions">
+              <div
+                style={{
+                  display: 'flex',
+                  justifyContent: 'center',
+                  width: '100%',
+                  backgroundColor: theme.mobilePageBackground,
+                }}
+              >
+                <Text style={{ fontSize: 15 }}>No transactions</Text>
+              </div>
+            </Item>
+          </Section>
+        ) : null}
+        {sections.map(section => {
+          return (
+            <Section
+              title={
+                <span>{monthUtils.format(section.date, 'MMMM dd, yyyy')}</span>
+              }
+              key={section.id}
+            >
+              {section.data.map((transaction, index, transactions) => {
+                return (
+                  <Item
+                    key={transaction.id}
+                    style={{
+                      fontSize:
+                        index === transactions.length - 1 ? 98 : 'inherit',
+                    }}
+                    textValue={transaction.id}
+                  >
+                    <Transaction
+                      transaction={transaction}
+                      account={account}
+                      categories={categories}
+                      accounts={accounts}
+                      payees={payees}
+                      added={isNew(transaction.id)}
+                      onSelect={onSelect} // onSelect(transaction)}
+                    />
+                  </Item>
+                );
+              })}
+            </Section>
+          );
+        })}
+      </ListBox>
+    </>
+  );
+}
diff --git a/packages/desktop-client/src/components/responsive/narrow.ts b/packages/desktop-client/src/components/responsive/narrow.ts
index 900ed9b94..d1d2b5cb4 100644
--- a/packages/desktop-client/src/components/responsive/narrow.ts
+++ b/packages/desktop-client/src/components/responsive/narrow.ts
@@ -1,4 +1,4 @@
-export { Budget } from '../budget/MobileBudget';
+export { Budget } from '../mobile/budget';
 
-export { Accounts } from '../accounts/MobileAccounts';
-export { Account } from '../accounts/MobileAccount';
+export { Accounts } from '../mobile/accounts/Accounts';
+export { Account } from '../mobile/accounts/Account';
diff --git a/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx b/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx
index 350470327..dad536a1d 100644
--- a/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx
+++ b/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx
@@ -1,13 +1,12 @@
 import React, { useMemo } from 'react';
 
+import { isPreviewId } from 'loot-core/shared/transactions';
 import { validForTransfer } from 'loot-core/src/client/transfer';
 
 import { useSelectedItems } from '../../hooks/useSelected';
 import { Menu } from '../common/Menu';
 import { SelectedItemsButton } from '../table';
 
-import { isPreviewId } from './TransactionsTable';
-
 export function SelectedTransactionsButton({
   getTransaction,
   onShow,
diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
index c5e853fa5..2b98a047e 100644
--- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
+++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
@@ -1,5 +1,4 @@
 import React, {
-  createContext,
   createElement,
   createRef,
   forwardRef,
@@ -10,10 +9,7 @@ import React, {
   useCallback,
   useLayoutEffect,
   useEffect,
-  useContext,
-  useReducer,
 } from 'react';
-import { useSelector, useDispatch } from 'react-redux';
 
 import {
   format as formatDate,
@@ -37,6 +33,8 @@ import {
   addSplitTransaction,
   groupTransaction,
   ungroupTransactions,
+  isTemporaryId,
+  isPreviewId,
 } from 'loot-core/src/shared/transactions';
 import {
   integerToCurrency,
@@ -47,6 +45,7 @@ import {
 import { useMergedRefs } from '../../hooks/useMergedRefs';
 import { usePrevious } from '../../hooks/usePrevious';
 import { useSelectedDispatch, useSelectedItems } from '../../hooks/useSelected';
+import { useSplitsExpanded } from '../../hooks/useSplitsExpanded';
 import { SvgLeftArrow2, SvgRightArrow2 } from '../../icons/v0';
 import { SvgArrowDown, SvgArrowUp, SvgCheveronDown } from '../../icons/v1';
 import {
@@ -149,104 +148,6 @@ function isLastChild(transactions, index) {
   );
 }
 
-const SplitsExpandedContext = createContext(null);
-
-export function useSplitsExpanded() {
-  const data = useContext(SplitsExpandedContext);
-
-  return useMemo(
-    () => ({
-      ...data,
-      expanded: id =>
-        data.state.mode === 'collapse'
-          ? !data.state.ids.has(id)
-          : data.state.ids.has(id),
-    }),
-    [data],
-  );
-}
-
-export function SplitsExpandedProvider({ children, initialMode = 'expand' }) {
-  const cachedState = useSelector(state => state.app.lastSplitState);
-  const reduxDispatch = useDispatch();
-
-  const [state, dispatch] = useReducer(
-    (state, action) => {
-      switch (action.type) {
-        case 'toggle-split': {
-          const ids = new Set([...state.ids]);
-          const { id } = action;
-          if (ids.has(id)) {
-            ids.delete(id);
-          } else {
-            ids.add(id);
-          }
-          return { ...state, ids };
-        }
-        case 'open-split': {
-          const ids = new Set([...state.ids]);
-          const { id } = action;
-          if (state.mode === 'collapse') {
-            ids.delete(id);
-          } else {
-            ids.add(id);
-          }
-          return { ...state, ids };
-        }
-        case 'set-mode': {
-          return {
-            ...state,
-            mode: action.mode,
-            ids: new Set(),
-            transitionId: null,
-          };
-        }
-        case 'switch-mode':
-          if (state.transitionId != null) {
-            // You can only transition once at a time
-            return state;
-          }
-
-          return {
-            ...state,
-            mode: state.mode === 'expand' ? 'collapse' : 'expand',
-            transitionId: action.id,
-            ids: new Set(),
-          };
-        case 'finish-switch-mode':
-          return { ...state, transitionId: null };
-        default:
-          throw new Error('Unknown action type: ' + action.type);
-      }
-    },
-    cachedState.current || { ids: new Set(), mode: initialMode },
-  );
-
-  useEffect(() => {
-    if (state.transitionId != null) {
-      // This timeout allows animations to finish
-      setTimeout(() => {
-        dispatch({ type: 'finish-switch-mode' });
-      }, 250);
-    }
-  }, [state.transitionId]);
-
-  useEffect(() => {
-    // In a finished state, cache the state
-    if (state.transitionId == null) {
-      reduxDispatch({ type: 'SET_LAST_SPLIT_STATE', splitState: state });
-    }
-  }, [state]);
-
-  const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);
-
-  return (
-    <SplitsExpandedContext.Provider value={value}>
-      {children}
-    </SplitsExpandedContext.Provider>
-  );
-}
-
 function selectAscDesc(field, ascDesc, clicked, defaultAscDesc = 'asc') {
   return field === clicked
     ? ascDesc === 'asc'
@@ -1458,14 +1359,6 @@ function makeTemporaryTransactions(
   ];
 }
 
-function isTemporaryId(id) {
-  return id.indexOf('temp') !== -1;
-}
-
-export function isPreviewId(id) {
-  return id.indexOf('preview/') !== -1;
-}
-
 function NewTransaction({
   transactions,
   accounts,
diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx
index 2d1e51c2e..3b03b5b4f 100644
--- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx
+++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx
@@ -21,9 +21,10 @@ import {
 import { integerToCurrency } from 'loot-core/src/shared/util';
 
 import { SelectedProviderWithItems } from '../../hooks/useSelected';
+import { SplitsExpandedProvider } from '../../hooks/useSplitsExpanded';
 import { ResponsiveProvider } from '../../ResponsiveProvider';
 
-import { SplitsExpandedProvider, TransactionTable } from './TransactionsTable';
+import { TransactionTable } from './TransactionsTable';
 
 vi.mock('loot-core/src/platform/client/fetch');
 vi.mock('../../hooks/useFeatureFlag', () => vi.fn().mockReturnValue(false));
diff --git a/packages/desktop-client/src/hooks/useSplitsExpanded.jsx b/packages/desktop-client/src/hooks/useSplitsExpanded.jsx
new file mode 100644
index 000000000..15e1a3cde
--- /dev/null
+++ b/packages/desktop-client/src/hooks/useSplitsExpanded.jsx
@@ -0,0 +1,106 @@
+import React, {
+  createContext,
+  useMemo,
+  useEffect,
+  useContext,
+  useReducer,
+} from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+
+const SplitsExpandedContext = createContext(null);
+
+export function useSplitsExpanded() {
+  const data = useContext(SplitsExpandedContext);
+
+  return useMemo(
+    () => ({
+      ...data,
+      expanded: id =>
+        data.state.mode === 'collapse'
+          ? !data.state.ids.has(id)
+          : data.state.ids.has(id),
+    }),
+    [data],
+  );
+}
+
+export function SplitsExpandedProvider({ children, initialMode = 'expand' }) {
+  const cachedState = useSelector(state => state.app.lastSplitState);
+  const reduxDispatch = useDispatch();
+
+  const [state, dispatch] = useReducer(
+    (state, action) => {
+      switch (action.type) {
+        case 'toggle-split': {
+          const ids = new Set([...state.ids]);
+          const { id } = action;
+          if (ids.has(id)) {
+            ids.delete(id);
+          } else {
+            ids.add(id);
+          }
+          return { ...state, ids };
+        }
+        case 'open-split': {
+          const ids = new Set([...state.ids]);
+          const { id } = action;
+          if (state.mode === 'collapse') {
+            ids.delete(id);
+          } else {
+            ids.add(id);
+          }
+          return { ...state, ids };
+        }
+        case 'set-mode': {
+          return {
+            ...state,
+            mode: action.mode,
+            ids: new Set(),
+            transitionId: null,
+          };
+        }
+        case 'switch-mode':
+          if (state.transitionId != null) {
+            // You can only transition once at a time
+            return state;
+          }
+
+          return {
+            ...state,
+            mode: state.mode === 'expand' ? 'collapse' : 'expand',
+            transitionId: action.id,
+            ids: new Set(),
+          };
+        case 'finish-switch-mode':
+          return { ...state, transitionId: null };
+        default:
+          throw new Error('Unknown action type: ' + action.type);
+      }
+    },
+    cachedState.current || { ids: new Set(), mode: initialMode },
+  );
+
+  useEffect(() => {
+    if (state.transitionId != null) {
+      // This timeout allows animations to finish
+      setTimeout(() => {
+        dispatch({ type: 'finish-switch-mode' });
+      }, 250);
+    }
+  }, [state.transitionId]);
+
+  useEffect(() => {
+    // In a finished state, cache the state
+    if (state.transitionId == null) {
+      reduxDispatch({ type: 'SET_LAST_SPLIT_STATE', splitState: state });
+    }
+  }, [reduxDispatch, state]);
+
+  const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);
+
+  return (
+    <SplitsExpandedContext.Provider value={value}>
+      {children}
+    </SplitsExpandedContext.Provider>
+  );
+}
diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts
index 72cd5dbc5..c4d54d17d 100644
--- a/packages/loot-core/src/client/state-types/modals.d.ts
+++ b/packages/loot-core/src/client/state-types/modals.d.ts
@@ -116,15 +116,14 @@ type FinanceModals = {
   'schedule-posts-offline-notification': null;
   'switch-budget-type': { onSwitch: () => void };
   'category-menu': {
-    category: CategoryEntity;
+    categoryId: string;
     onSave: (category: CategoryEntity) => void;
     onEditNotes: (id: string) => void;
-    onSaveNotes: (id: string, notes: string) => void;
     onDelete: (categoryId: string) => void;
     onClose?: () => void;
   };
   'category-group-menu': {
-    group: CategoryGroupEntity;
+    groupId: string;
     onSave: (group: CategoryGroupEntity) => void;
     onAddCategory: (groupId: string, isIncome: boolean) => void;
     onEditNotes: (id: string) => void;
diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts
index 6142b1cc0..a1a87df9f 100644
--- a/packages/loot-core/src/shared/transactions.ts
+++ b/packages/loot-core/src/shared/transactions.ts
@@ -12,6 +12,10 @@ interface TransactionEntityWithError extends TransactionEntity {
   _deleted?: boolean;
 }
 
+export function isTemporaryId(id: string) {
+  return id.indexOf('temp') !== -1;
+}
+
 export function isPreviewId(id: string) {
   return id.indexOf('preview/') !== -1;
 }
diff --git a/upcoming-release-notes/2425.md b/upcoming-release-notes/2425.md
new file mode 100644
index 000000000..62598bd67
--- /dev/null
+++ b/upcoming-release-notes/2425.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [joel-jeremy]
+---
+
+Reorganize mobile components.
diff --git a/yarn.lock b/yarn.lock
index a6c906914..53cfcdcb7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -117,6 +117,7 @@ __metadata:
     sass: "npm:^1.70.0"
     swc-loader: "npm:^0.2.3"
     terser-webpack-plugin: "npm:^5.3.10"
+    usehooks-ts: "npm:^3.0.1"
     uuid: "npm:^9.0.1"
     vite: "npm:^5.0.12"
     vite-plugin-pwa: "npm:^0.19.0"
@@ -17878,6 +17879,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"usehooks-ts@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "usehooks-ts@npm:3.0.1"
+  dependencies:
+    lodash.debounce: "npm:^4.0.8"
+  peerDependencies:
+    react: ^16.8.0  || ^17 || ^18
+  checksum: ad80424c4e7613118a1333af63220efb06a63368d1cd0b9baa96bf40615e656ab93429a32821061e86127f8de77058423accc959d7c06aa6e15823f4765d9e5b
+  languageName: node
+  linkType: hard
+
 "utf8-byte-length@npm:^1.0.1":
   version: 1.0.4
   resolution: "utf8-byte-length@npm:1.0.4"
-- 
GitLab