diff --git a/reviewboard/actions/__init__.py b/reviewboard/actions/__init__.py
index 52a975fd1ad084c726654ce84a7cefa11f359866..9fcac171efaa24c282302f231e5a3d1781a9ded2 100644
--- a/reviewboard/actions/__init__.py
+++ b/reviewboard/actions/__init__.py
@@ -8,6 +8,7 @@ from djblets.registries.importer import lazy_import_registry
 
 from reviewboard.actions.base import (AttachmentPoint,
                                       BaseAction,
+                                      BaseGroupAction,
                                       BaseMenuAction,
                                       QuickAccessActionMixin)
 
@@ -20,6 +21,7 @@ actions_registry = lazy_import_registry(
 __all__ = [
     'AttachmentPoint',
     'BaseAction',
+    'BaseGroupAction',
     'BaseMenuAction',
     'QuickAccessActionMixin',
     'actions_registry',
@@ -28,6 +30,7 @@ __all__ = [
 __autodoc_excludes__ = [
     'AttachmentPoint',
     'BaseAction',
+    'BaseGroupAction',
     'BaseMenuAction',
     'QuickAccessActionMixin',
 ]
diff --git a/reviewboard/actions/base.py b/reviewboard/actions/base.py
index af86d0f2b41a932acaf52dac420d34260e6ddbf5..0a19956a4d83c7c7fc37121391a4ed6066b1a1ac 100644
--- a/reviewboard/actions/base.py
+++ b/reviewboard/actions/base.py
@@ -12,14 +12,19 @@ from typing import Any, List, Mapping, Optional, TYPE_CHECKING, cast
 from django.template.loader import render_to_string
 from django.utils.safestring import SafeText, mark_safe
 
+from reviewboard.actions.errors import ActionError
 from reviewboard.site.urlresolvers import local_site_reverse
 
 if TYPE_CHECKING:
+    from collections.abc import Sequence
+
     from django.http import HttpRequest
     from django.template import Context
     from typelets.django.json import SerializableDjangoJSONDict
     from typelets.django.strings import StrOrPromise
 
+    from reviewboard.actions.registry import ActionsRegistry
+
 
 logger = logging.getLogger(__name__)
 
@@ -168,22 +173,23 @@ class BaseAction:
     # Instance variables #
     ######################
 
-    #: The list of child actions, if this is a menu.
-    #:
-    #: Type:
-    #:     list of BaseAction
-    child_actions: List[BaseAction]
+    #: The list of child actions, if this is a grouped action.
+    child_actions: list[BaseAction]
+
+    #: The parent of this action, if this is an item in a group.
+    parent_action: BaseGroupAction | None
 
-    #: The parent of this action, if this is a menu item.
+    #: The parent registry managing this action.
     #:
-    #: Type:
-    #:     BaseMenuAction
-    parent_action: Optional[BaseMenuAction]
+    #: Version Added:
+    #:     7.1
+    parent_registry: ActionsRegistry | None
 
     def __init__(self) -> None:
         """Initialize the action."""
         self.parent_action = None
         self.child_actions = []
+        self.parent_registry = None
 
     @property
     def depth(self) -> int:
@@ -507,41 +513,26 @@ class BaseAction:
             return mark_safe('')
 
 
-class BaseMenuAction(BaseAction):
-    """Base class for menu actions.
+class BaseGroupAction(BaseAction):
+    """Base class for a group of actions.
+
+    This can be used to group together actions in some form. Subclasses
+    can implement this as menus, lists of actions, or in other
+    presentational styles.
 
     Version Added:
-        6.0
+        7.1
     """
 
-    template_name = 'actions/menu_action.html'
-    js_model_class = 'RB.Actions.MenuAction'
-    js_view_class = 'RB.Actions.MenuActionView'
+    js_model_class = 'RB.Actions.GroupAction'
 
-    #: An ordered list of child menu IDs.
+    #: An ordered list of child action IDs.
     #:
     #: This can be used to specify a specific order for children to appear in.
     #: The special string '--' can be used to add separators. Any children that
-    #: are registered with this menu as their parent but do not appear in this
-    #: list will be added at the end of the menu.
-    children: List[str] = []
-
-    def is_custom_rendered(self) -> bool:
-        """Whether this menu action uses custom rendering.
-
-        By default, this will return ``True`` if a custom template name is
-        used. If the JavaScript side needs to override rendering, the subclass
-        should explicitly return ``True``.
-
-        Version Added:
-            7.0
-
-        Returns:
-            bool:
-            ``True`` if this action uses custom rendering. ``False`` if it
-            does not.
-        """
-        return self.template_name != BaseMenuAction.template_name
+    #: are registered with this group as their parent but do not appear in this
+    #: list will be added at the end of the group.
+    children: Sequence[str] = []
 
     def get_extra_context(
         self,
@@ -551,9 +542,9 @@ class BaseMenuAction(BaseAction):
     ) -> dict:
         """Return extra template context for the action.
 
-        Subclasses can override this to provide additional context needed by
-        the template for the action. By default, this returns an empty
-        dictionary.
+        This provides all the children that can be rendered in the group.
+
+        Subclasses can override this to provide additional context.
 
         Args:
             request (django.http.HttpRequest):
@@ -565,16 +556,32 @@ class BaseMenuAction(BaseAction):
         Returns:
             dict:
             Extra context to use when rendering the action's template.
+
+        Raises:
+            reviewboard.actions.errors.ActionError:
+                There was an error retrieving data for the action.
+
+                Details will be in the error message.
         """
-        from reviewboard.actions import actions_registry
+        registry = self.parent_registry
+
+        if not registry:
+            raise ActionError(
+                f'Attempted to call get_extra_context on {self!r} without '
+                f'first being registered.'
+            )
 
         extra_context = super().get_extra_context(request=request,
                                                   context=context)
-        extra_context['children'] = ([
+
+        action_id = self.action_id
+        assert action_id
+
+        extra_context['children'] = [
             child
-            for child in actions_registry.get_children(self.action_id)
+            for child in registry.get_children(action_id)
             if child.should_render(context=context)
-        ])
+        ]
 
         return extra_context
 
@@ -592,16 +599,31 @@ class BaseMenuAction(BaseAction):
         Returns:
             dict:
             A dictionary of attributes to pass to the model instance.
+
+        Raises:
+            reviewboard.actions.errors.ActionError:
+                There was an error retrieving data for the action.
+
+                Details will be in the error message.
         """
-        from reviewboard.actions import actions_registry
+        registry = self.parent_registry
 
-        rendered_child_ids = [
-            child.action_id
-            for child in actions_registry.get_children(self.action_id)
-            if child.should_render(context=context)
-        ]
+        if not registry:
+            raise ActionError(
+                f'Attempted to call get_js_model_data on {self!r} without '
+                f'first being registered.'
+            )
+
+        action_id = self.action_id
+        assert action_id is not None
+
+        rendered_child_ids: dict[str, bool] = {
+            child.action_id: True
+            for child in registry.get_children(action_id)
+            if child.action_id and child.should_render(context=context)
+        }
 
-        children = []
+        children: list[str] = []
 
         # Add in any children with explicit ordering first.
         for child_id in self.children:
@@ -609,11 +631,10 @@ class BaseMenuAction(BaseAction):
                 children.append(child_id)
             elif child_id in rendered_child_ids:
                 children.append(child_id)
-                rendered_child_ids.remove(child_id)
+                del rendered_child_ids[child_id]
 
         # Now add any other actions that weren't in self.children.
-        for child_id in rendered_child_ids:
-            children.append(child_id)
+        children += rendered_child_ids.keys()
 
         data = super().get_js_model_data(context=context)
         data['children'] = children
@@ -621,6 +642,35 @@ class BaseMenuAction(BaseAction):
         return data
 
 
+class BaseMenuAction(BaseGroupAction):
+    """Base class for menu actions.
+
+    Version Added:
+        6.0
+    """
+
+    template_name = 'actions/menu_action.html'
+    js_model_class = 'RB.Actions.MenuAction'
+    js_view_class = 'RB.Actions.MenuActionView'
+
+    def is_custom_rendered(self) -> bool:
+        """Whether this menu action uses custom rendering.
+
+        By default, this will return ``True`` if a custom template name is
+        used. If the JavaScript side needs to override rendering, the subclass
+        should explicitly return ``True``.
+
+        Version Added:
+            7.0
+
+        Returns:
+            bool:
+            ``True`` if this action uses custom rendering. ``False`` if it
+            does not.
+        """
+        return self.template_name != BaseMenuAction.template_name
+
+
 if TYPE_CHECKING:
     BaseQuickAccessActionMixin = BaseAction
 else:
diff --git a/reviewboard/actions/errors.py b/reviewboard/actions/errors.py
index 0839b7a64410e6ba5b1f9cce8d7204e6faf32382..d125201f535cad740988e03401121a599ab9daf9 100644
--- a/reviewboard/actions/errors.py
+++ b/reviewboard/actions/errors.py
@@ -5,6 +5,14 @@ Version Added:
 """
 
 
+class ActionError(Exception):
+    """Base class for action-related errors.
+
+    Version:
+        7.1
+    """
+
+
 class DepthLimitExceededError(ValueError):
     """An error that occurs when the maximum depth limit is exceeded.
 
diff --git a/reviewboard/actions/registry.py b/reviewboard/actions/registry.py
index e9be2f7f866c97dee5234b4e93e8a5166b584ded..8f28a13a1846a0fd7ce6128b9d7cf0969913e5f8 100644
--- a/reviewboard/actions/registry.py
+++ b/reviewboard/actions/registry.py
@@ -209,6 +209,8 @@ class ActionsRegistry(OrderedRegistry):
 
         super().register(action)
 
+        action.parent_registry = self
+
         # Store this by attachment point, for quick lookup.
         if (attachment := action.attachment):
             try:
@@ -261,6 +263,8 @@ class ActionsRegistry(OrderedRegistry):
 
         super().unregister(action)
 
+        action.parent_registry = None
+
     def get_for_attachment(
         self,
         attachment: str,
@@ -305,6 +309,9 @@ class ActionsRegistry(OrderedRegistry):
             The actions that are contained within the menu.
         """
         parent = self.get('action_id', parent_id)
-        assert parent is not None
+        assert parent is not None, (
+            f'Action {parent_id!r} was not registered when calling '
+            f'get_children().'
+        )
 
         yield from parent.child_actions
diff --git a/reviewboard/actions/tests/base.py b/reviewboard/actions/tests/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..de01d242cb7de36af783b62a3cd379108f882680
--- /dev/null
+++ b/reviewboard/actions/tests/base.py
@@ -0,0 +1,158 @@
+"""Base support for action unit tests.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from reviewboard.actions import (AttachmentPoint,
+                                 BaseAction,
+                                 BaseGroupAction,
+                                 BaseMenuAction)
+from reviewboard.actions.registry import ActionsRegistry
+
+if TYPE_CHECKING:
+    from collections.abc import Iterator
+
+
+class TestAction(BaseAction):
+    """Basic action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'test'
+
+
+class TestHeaderAction(BaseAction):
+    """Basic header action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'header-action'
+    attachment = AttachmentPoint.HEADER
+
+
+class TestGroupAction(BaseGroupAction):
+    """Basic group action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'group-action'
+
+    children = [
+        'group-item-2-action',
+        'group-item-1-action',
+    ]
+
+
+class TestGroupItemAction1(BaseAction):
+    """Basic group item action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'group-item-1-action'
+    parent_id = 'group-action'
+
+
+class TestGroupItemAction2(BaseAction):
+    """Basic group item action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'group-item-2-action'
+    parent_id = 'group-action'
+
+
+class TestGroupItemAction3(BaseAction):
+    """Basic group item action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'group-item-3-action'
+    parent_id = 'group-action'
+
+
+class TestMenuAction(BaseMenuAction):
+    """Basic menu action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'menu-action'
+
+
+class TestMenuItemAction(BaseAction):
+    """Basic menu item action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'menu-item-action'
+    parent_id = 'menu-action'
+
+
+class TestNestedMenuAction(BaseMenuAction):
+    """Basic nested menu action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'nested-menu-action'
+    parent_id = 'menu-action'
+
+
+class TestNested2MenuAction(BaseMenuAction):
+    """Second-level nested menu action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'nested-2-menu-action'
+    parent_id = 'nested-menu-action'
+
+
+class TooDeeplyNestedAction(BaseAction):
+    """Third-level (too-deep) nested menu action for testing.
+
+    Version Added:
+        7.1
+    """
+
+    action_id = 'nested-3-action'
+    parent_id = 'nested-2-menu-action'
+
+
+class TestActionsRegistry(ActionsRegistry):
+    """Empty actions registry for testing purposes.
+
+    Version Added:
+        7.1
+    """
+
+    def get_defaults(self) -> Iterator[BaseAction]:
+        """Return an empty set of defaults.
+
+        Yields:
+            reviewboard.actions.base.BaseAction:
+            Each action (but none, really).
+        """
+        yield from []
diff --git a/reviewboard/actions/tests/test_group_action.py b/reviewboard/actions/tests/test_group_action.py
new file mode 100644
index 0000000000000000000000000000000000000000..b5f12e81d7a94e9f6aef0269a8aacf7fcbfbda49
--- /dev/null
+++ b/reviewboard/actions/tests/test_group_action.py
@@ -0,0 +1,122 @@
+"""Unit tests for reviewboard.actions.base.BaseGroupAction.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+from django.template import Context
+
+from reviewboard.actions.tests.base import (
+    TestActionsRegistry,
+    TestGroupAction,
+    TestGroupItemAction1,
+    TestGroupItemAction2,
+    TestGroupItemAction3,
+)
+from reviewboard.testing import TestCase
+
+
+class BaseGroupActionTests(TestCase):
+    """Unit tests for BaseGroupAction.
+
+    Version Added:
+        7.1
+    """
+
+    ######################
+    # Instance variables #
+    ######################
+
+    #: A BaseGroupAction instance for testing.
+    group_action: TestGroupAction
+
+    #: Item 1's action, for testing.
+    item1_action: TestGroupItemAction1
+
+    #: Item 2's action, for testing.
+    item2_action: TestGroupItemAction2
+
+    #: Item 3's action, for testing.
+    item3_action: TestGroupItemAction3
+
+    #: An empty actions registry for testing.
+    registry: TestActionsRegistry
+
+    def setUp(self) -> None:
+        """Set up state for a test."""
+        super().setUp()
+
+        group_action = TestGroupAction()
+        item1_action = TestGroupItemAction1()
+        item2_action = TestGroupItemAction2()
+        item3_action = TestGroupItemAction3()
+
+        registry = TestActionsRegistry()
+        registry.register(group_action)
+        registry.register(item1_action)
+        registry.register(item2_action)
+        registry.register(item3_action)
+
+        self.registry = registry
+        self.group_action = group_action
+        self.item1_action = item1_action
+        self.item2_action = item2_action
+        self.item3_action = item3_action
+
+    def tearDown(self) -> None:
+        """Tear down state for a test."""
+
+        del self.group_action
+        del self.item1_action
+        del self.item2_action
+        del self.item3_action
+        del self.registry
+
+        super().tearDown()
+
+    def test_get_extra_context(self) -> None:
+        """Testing BaseGroupAction.get_extra_context"""
+        request = self.create_http_request()
+        context = Context({
+            'request': request,
+        })
+
+        self.assertEqual(
+            self.group_action.get_extra_context(request=request,
+                                                context=context),
+            {
+                'children': [
+                    self.item1_action,
+                    self.item2_action,
+                    self.item3_action,
+                ],
+                'has_parent': False,
+                'id': 'group-action',
+                'label': None,
+                'url': '#',
+                'verbose_label': None,
+                'visible': True,
+            })
+
+    def test_js_model_data(self) -> None:
+        """Testing BaseGroupAction.js_model_data"""
+        request = self.create_http_request()
+        context = Context({
+            'request': request,
+        })
+
+        self.assertEqual(
+            self.group_action.get_js_model_data(context=context),
+            {
+                'actionId': 'group-action',
+                'children': [
+                    'group-item-2-action',
+                    'group-item-1-action',
+                    'group-item-3-action',
+                ],
+                'domID': 'action-group-action',
+                'url': '#',
+                'visible': True,
+            })
diff --git a/reviewboard/actions/tests/test_registry.py b/reviewboard/actions/tests/test_registry.py
index 5c7ddcb9d07b9af234bcc14479c54cab0ee4e5a4..1dbadc0b98ab575838c26c09782e44fa4d3d0168 100644
--- a/reviewboard/actions/tests/test_registry.py
+++ b/reviewboard/actions/tests/test_registry.py
@@ -6,71 +6,23 @@ Version Added:
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
-
 from djblets.registries.errors import AlreadyRegisteredError
 
 from reviewboard.actions import (AttachmentPoint,
-                                 BaseAction,
-                                 BaseMenuAction,
                                  actions_registry)
 from reviewboard.actions.errors import DepthLimitExceededError
-from reviewboard.actions.registry import ActionsRegistry
+from reviewboard.actions.tests.base import (
+    TestAction,
+    TestActionsRegistry,
+    TestHeaderAction,
+    TestMenuAction,
+    TestMenuItemAction,
+    TestNested2MenuAction,
+    TestNestedMenuAction,
+    TooDeeplyNestedAction,
+)
 from reviewboard.testing import TestCase
 
-if TYPE_CHECKING:
-    from collections.abc import Iterator
-
-
-class TestAction(BaseAction):
-    action_id = 'test'
-
-
-class TestHeaderAction(BaseAction):
-    action_id = 'header-action'
-    attachment = AttachmentPoint.HEADER
-
-
-class TestMenuAction(BaseMenuAction):
-    action_id = 'menu-action'
-
-
-class TestMenuItemAction(BaseAction):
-    action_id = 'menu-item-action'
-    parent_id = 'menu-action'
-
-
-class TestNestedMenuAction(BaseMenuAction):
-    action_id = 'nested-menu-action'
-    parent_id = 'menu-action'
-
-
-class TestNested2MenuAction(BaseMenuAction):
-    action_id = 'nested-2-menu-action'
-    parent_id = 'nested-menu-action'
-
-
-class TooDeeplyNestedAction(BaseAction):
-    action_id = 'nested-3-action'
-    parent_id = 'nested-2-menu-action'
-
-
-class _TestActionsRegistry(ActionsRegistry):
-    """Empty actions registry for testing purposes.
-
-    Version Added:
-        7.1
-    """
-
-    def get_defaults(self) -> Iterator[BaseAction]:
-        """Return an empty set of defaults.
-
-        Yields:
-            reviewboard.actions.base.BaseAction:
-            Each action (but none, really).
-        """
-        yield from []
-
 
 class ActionsRegistryTests(TestCase):
     """Unit tests for the actions registry.
@@ -97,7 +49,7 @@ class ActionsRegistryTests(TestCase):
         """Testing ActionsRegistry.register"""
         test_action = self.test_action
 
-        actions_registry = _TestActionsRegistry()
+        actions_registry = TestActionsRegistry()
         actions_registry.register(test_action)
 
         with self.assertRaises(AlreadyRegisteredError):
@@ -115,7 +67,7 @@ class ActionsRegistryTests(TestCase):
         """Testing ActionsRegistry.unregister"""
         test_action = self.test_action
 
-        actions_registry = _TestActionsRegistry()
+        actions_registry = TestActionsRegistry()
         actions_registry.register(test_action)
 
         self.assertEqual(
@@ -136,7 +88,7 @@ class ActionsRegistryTests(TestCase):
 
     def test_get_for_attachment(self) -> None:
         """Testing ActionsRegistry.get_for_attachment"""
-        actions_registry = _TestActionsRegistry()
+        actions_registry = TestActionsRegistry()
 
         test_action = self.test_action
         test_header_action = self.test_header_action
diff --git a/reviewboard/static/rb/js/common/actions/index.ts b/reviewboard/static/rb/js/common/actions/index.ts
index 8b37f8b2ee5a96d2c3a854bac658d5327a066a04..2afab86234f2cfa23b621754315892dd850e13c2 100644
--- a/reviewboard/static/rb/js/common/actions/index.ts
+++ b/reviewboard/static/rb/js/common/actions/index.ts
@@ -1,4 +1,5 @@
 import { Action } from './models/actionModel';
+import { GroupAction } from './models/groupActionModel';
 import { MenuAction } from './models/menuActionModel';
 import { ActionView } from './views/actionView';
 import { MenuActionView, MenuItemActionView } from './views/menuActionView';
@@ -7,6 +8,7 @@ import { MenuActionView, MenuItemActionView } from './views/menuActionView';
 export const Actions = {
     Action,
     ActionView,
+    GroupAction,
     MenuAction,
     MenuActionView,
     MenuItemActionView,
diff --git a/reviewboard/static/rb/js/common/actions/models/groupActionModel.ts b/reviewboard/static/rb/js/common/actions/models/groupActionModel.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bbe78c3f2e4e72bb516a21f3a62590ed7acaedf1
--- /dev/null
+++ b/reviewboard/static/rb/js/common/actions/models/groupActionModel.ts
@@ -0,0 +1,41 @@
+import {
+    type Result,
+    spina,
+} from '@beanbag/spina';
+
+import {
+    type ActionAttrs,
+    Action,
+} from './actionModel';
+
+
+/**
+ * Attributes for the GroupAction model.
+ *
+ * Version Added:
+ *     6.0
+ */
+export interface GroupActionAttrs extends ActionAttrs {
+    /**
+     * The IDs of the child actions.
+     */
+    children: string[];
+}
+
+
+/**
+ * Base model for menu actions.
+ *
+ * Version Added:
+ *     6.0
+ */
+@spina
+export class GroupAction<
+    TAttrs extends GroupActionAttrs
+> extends Action<TAttrs> {
+    static defaults(): Result<Partial<GroupActionAttrs>> {
+        return {
+            children: [],
+        };
+    }
+}
diff --git a/reviewboard/static/rb/js/common/actions/models/menuActionModel.ts b/reviewboard/static/rb/js/common/actions/models/menuActionModel.ts
index 157ed778c59065dfdfaa544bacd44d10f2231997..2d8b2f0fcbed88d0a2cba68a9970822ee2782bbf 100644
--- a/reviewboard/static/rb/js/common/actions/models/menuActionModel.ts
+++ b/reviewboard/static/rb/js/common/actions/models/menuActionModel.ts
@@ -1,12 +1,11 @@
 import {
-    type Result,
     spina,
 } from '@beanbag/spina';
 
 import {
-    type ActionAttrs,
-    Action,
-} from './actionModel';
+    type GroupActionAttrs,
+    GroupAction,
+} from './groupActionModel';
 
 
 /**
@@ -15,12 +14,7 @@ import {
  * Version Added:
  *     6.0
  */
-interface MenuActionAttrs extends ActionAttrs {
-    /**
-     * The IDs of the child actions.
-     */
-    children: string[];
-}
+export interface MenuActionAttrs extends GroupActionAttrs {}
 
 
 /**
@@ -30,10 +24,7 @@ interface MenuActionAttrs extends ActionAttrs {
  *     6.0
  */
 @spina
-export class MenuAction extends Action<MenuActionAttrs> {
-    static defaults(): Result<Partial<MenuActionAttrs>> {
-        return {
-            children: [],
-        };
-    }
+export class MenuAction<
+    TAttrs extends MenuActionAttrs
+> extends GroupAction<TAttrs> {
 }
