diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 43e29b17a..2b030af8b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -117,10 +117,6 @@ Most components are named according to the combination of a resource and an acti
 * `Edit` or `Update`. A component used to update an existing resource of a particular type.
 * `Delete`. A modal used to delete an existing resource of a particular type.
 
-### Vue Mixins
-
-Each component may use one or more mixins. Each file in [`/src/mixins/`](/src/mixins/) exports a mixin factory for a single type of mixin. (We use factories so that the component can pass in options for the mixin. We don't use this pattern much anymore though, so we will likely change this when we move to Vue 3.)
-
 ### Composables
 
 Each component may use one or more of the composables in [`/src/composables/`](/src/composables/). Most composables will reside in that directory, but if it makes sense to group a composable with other functionality, it may be defined elsewhere. For example, the `useSessions()` composable is defined in [`/src/util/session.js`](/src/util/session.js).
diff --git a/src/components/analytics/list.vue b/src/components/analytics/list.vue
index e6ac99564..61fc1eddc 100644
--- a/src/components/analytics/list.vue
+++ b/src/components/analytics/list.vue
@@ -22,7 +22,7 @@ except according to the terms contained in the LICENSE file.
     </div>
     <loading :state="initiallyLoading"/>
     <template v-if="dataExists">
-      <analytics-form @preview="showModal('preview')"/>
+      <analytics-form @preview="previewModal.show()"/>
       <page-section v-if="audits.length !== 0">
         <template #heading>
           <span>{{ $t('auditsTitle') }}</span>
@@ -32,7 +32,7 @@ except according to the terms contained in the LICENSE file.
         </template>
       </page-section>
     </template>
-    <analytics-preview v-bind="preview" @hide="hideModal('preview')"/>
+    <analytics-preview v-bind="previewModal" @hide="previewModal.hide()"/>
   </div>
 </template>
 
@@ -43,8 +43,8 @@ import AuditTable from '../audit/table.vue';
 import Loading from '../loading.vue';
 import PageSection from '../page/section.vue';
 
-import modal from '../../mixins/modal';
 import { apiPaths } from '../../util/request';
+import { modalData } from '../../util/reactivity';
 import { useRequestData } from '../../request-data';
 
 export default {
@@ -56,19 +56,12 @@ export default {
     Loading,
     PageSection
   },
-  mixins: [modal()],
   setup() {
     const { analyticsConfig, createResource, resourceStates } = useRequestData();
     const audits = createResource('audits');
     return {
-      analyticsConfig, audits, ...resourceStates([analyticsConfig, audits])
-    };
-  },
-  data() {
-    return {
-      preview: {
-        state: false
-      }
+      analyticsConfig, audits, ...resourceStates([analyticsConfig, audits]),
+      previewModal: modalData()
     };
   },
   created() {
diff --git a/src/components/form-attachment/link-dataset.vue b/src/components/form-attachment/link-dataset.vue
index 57b2184ff..395dc7eb9 100644
--- a/src/components/form-attachment/link-dataset.vue
+++ b/src/components/form-attachment/link-dataset.vue
@@ -15,11 +15,13 @@ except according to the terms contained in the LICENSE file.
     <template #title>{{ $t('title') }}</template>
     <template #body>
       <div class="modal-introduction">
-          <p>
-            <span>{{ $t('introduction[0]') }}</span>
-            <sentence-separator/>
-            <template v-if="blobExists">{{ $t('introduction[1]') }}</template>
-          </p>
+        <p>
+          <span>{{ $t('introduction[0]') }}</span>
+          <sentence-separator/>
+          <template v-if="attachment != null && attachment.blobExists">
+            {{ $t('introduction[1]') }}
+          </template>
+        </p>
       </div>
       <div class="modal-actions">
         <button type="button" class="btn btn-primary btn-link-dataset"
@@ -53,14 +55,7 @@ export default {
       type: Boolean,
       default: false
     },
-    attachmentName: {
-      type: String,
-      required: true
-    },
-    blobExists: {
-      type: Boolean,
-      default: false
-    }
+    attachment: Object
   },
   emits: ['hide', 'success'],
   setup() {
@@ -72,7 +67,7 @@ export default {
     link() {
       this.request({
         method: 'PATCH',
-        url: apiPaths.formDraftAttachment(this.form.projectId, this.form.xmlFormId, this.attachmentName),
+        url: apiPaths.formDraftAttachment(this.form.projectId, this.form.xmlFormId, this.attachment.name),
         data: { dataset: true }
       })
         .then(() => {
diff --git a/src/components/form-attachment/list.vue b/src/components/form-attachment/list.vue
index 69c55d17f..2ce669d9b 100644
--- a/src/components/form-attachment/list.vue
+++ b/src/components/form-attachment/list.vue
@@ -14,7 +14,7 @@ except according to the terms contained in the LICENSE file.
     :styled="false" @dragenter="dragenter" @dragleave="dragleave" @drop="drop">
     <div class="heading-with-button">
       <button type="button" class="btn btn-primary"
-        @click="showModal('uploadFilesModal')">
+        @click="uploadFilesModal.show()">
         <span class="icon-cloud-upload"></span>{{ $t('action.upload') }}&hellip;
       </button>
       <p>{{ $t('heading[0]') }}</p>
@@ -56,7 +56,7 @@ except according to the terms contained in the LICENSE file.
           :planned-uploads="plannedUploads"
           :updated-attachments="updatedAttachments" :data-name="attachment.name"
           :linkable="attachment.type === 'file' && !!dsHashset && dsHashset.has(attachment.name.replace(/\.[^.]+$/i, ''))"
-          @link="showLinkDatasetModal($event)"/>
+          @link="linkDatasetModal.show({ attachment: $event })"/>
       </tbody>
     </table>
     <form-attachment-popups
@@ -67,12 +67,12 @@ except according to the terms contained in the LICENSE file.
       @confirm="uploadFiles" @cancel="cancelUploads"/>
 
     <form-attachment-upload-files v-bind="uploadFilesModal"
-      @hide="hideModal('uploadFilesModal')" @select="afterFileInputSelection"/>
-    <form-attachment-name-mismatch :state="nameMismatch.state"
-      :planned-uploads="plannedUploads" @hide="hideModal('nameMismatch')"
+      @hide="uploadFilesModal.hide()" @select="afterFileInputSelection"/>
+    <form-attachment-name-mismatch v-bind="nameMismatch"
+      :planned-uploads="plannedUploads" @hide="nameMismatch.hide()"
       @confirm="uploadFiles" @cancel="cancelUploads"/>
-    <form-attachment-link-dataset v-bind="linkDatasetModal" @hide="hideModal('linkDatasetModal')"
-      @success="afterLinkDataset"/>
+    <form-attachment-link-dataset v-bind="linkDatasetModal"
+      @hide="linkDatasetModal.hide()" @success="afterLinkDataset"/>
   </file-drop-zone>
 </template>
 
@@ -88,9 +88,9 @@ import FormAttachmentRow from './row.vue';
 import FormAttachmentUploadFiles from './upload-files.vue';
 import SentenceSeparator from '../sentence-separator.vue';
 
-import modal from '../../mixins/modal';
 import useRequest from '../../composables/request';
 import { apiPaths } from '../../util/request';
+import { modalData } from '../../util/reactivity';
 import { noop } from '../../util/util';
 import { useRequestData } from '../../request-data';
 
@@ -106,7 +106,6 @@ export default {
     FormAttachmentUploadFiles,
     SentenceSeparator
   },
-  mixins: [modal()],
   inject: ['alert'],
   props: {
     projectId: {
@@ -165,16 +164,9 @@ export default {
       },
       updatedAttachments: new Set(),
       // Modals
-      uploadFilesModal: {
-        state: false
-      },
-      nameMismatch: {
-        state: false
-      },
-      linkDatasetModal: {
-        state: false,
-        attachmentName: ''
-      }
+      uploadFilesModal: modalData(),
+      nameMismatch: modalData(),
+      linkDatasetModal: modalData()
     };
   },
   computed: {
@@ -203,7 +195,7 @@ export default {
     // FILE INPUT
 
     afterFileInputSelection(files) {
-      this.hideModal('uploadFilesModal');
+      this.uploadFilesModal.hide();
       this.updatedAttachments.clear();
       this.matchFilesToAttachments(files);
     },
@@ -247,7 +239,7 @@ export default {
         if (upload.file.name === upload.attachment.name)
           this.uploadFiles();
         else
-          this.showModal('nameMismatch');
+          this.nameMismatch.show();
       } else {
         // The else case can be reached even if this.countOfFilesOverDropZone
         // was 1, if the drop was not over a row.
@@ -367,24 +359,19 @@ export default {
         resend: false
       }).catch(noop);
     },
-    showLinkDatasetModal({ name, blobExists }) {
-      this.linkDatasetModal.attachmentName = name;
-      this.linkDatasetModal.blobExists = blobExists;
-      this.showModal('linkDatasetModal');
-    },
     afterLinkDataset() {
+      const { attachment } = this.linkDatasetModal;
+      this.linkDatasetModal.hide();
+
       this.alert.success(this.$t('alert.link', {
-        attachmentName: this.linkDatasetModal.attachmentName
+        attachmentName: attachment.name
       }));
 
       this.attachments.patch(() => {
-        const attachment = this.attachments.get(this.linkDatasetModal.attachmentName);
         attachment.datasetExists = true;
         attachment.blobExists = false;
         attachment.exists = true;
       });
-
-      this.hideModal('linkDatasetModal');
     }
   }
 };
diff --git a/src/components/form-attachment/row.vue b/src/components/form-attachment/row.vue
index 18e9c3ee8..286fd2c0b 100644
--- a/src/components/form-attachment/row.vue
+++ b/src/components/form-attachment/row.vue
@@ -52,7 +52,7 @@ except according to the terms contained in the LICENSE file.
         </template>
         <template v-else-if="linkable && !attachment.datasetExists">
           <button type="button" class="btn btn-primary btn-link-dataset"
-            @click="$emit('link', { name: attachment.name, blobExists: attachment.blobExists })">
+            @click="$emit('link', attachment)">
             <span class="icon-link"></span>
             <i18n-t keypath="action.linkDataset">
               <template #datasetName>{{ datasetName }}</template>
diff --git a/src/components/form/list.vue b/src/components/form/list.vue
index 24e5270e7..60d74019e 100644
--- a/src/components/form/list.vue
+++ b/src/components/form/list.vue
@@ -16,7 +16,7 @@ except according to the terms contained in the LICENSE file.
         <span>{{ $t('title') }}</span>
         <button v-if="project.dataExists && project.permits('form.create')"
           id="form-list-create-button" type="button" class="btn btn-primary"
-          @click="showModal('newForm')">
+          @click="createModal.show()">
           <span class="icon-plus-circle"></span>{{ $t('action.create') }}&hellip;
         </button>
         <form-sort v-model="sortMode"/>
@@ -31,7 +31,7 @@ except according to the terms contained in the LICENSE file.
         </p>
       </template>
     </page-section>
-    <form-new v-bind="newForm" @hide="hideModal('newForm')"
+    <form-new v-bind="createModal" @hide="createModal.hide()"
       @success="afterCreate"/>
   </div>
 </template>
@@ -43,15 +43,14 @@ import Loading from '../loading.vue';
 import PageSection from '../page/section.vue';
 import FormSort from './sort.vue';
 
-import modal from '../../mixins/modal';
 import sortFunctions from '../../util/sort';
 import useRoutes from '../../composables/routes';
+import { modalData } from '../../util/reactivity';
 import { useRequestData } from '../../request-data';
 
 export default {
   name: 'FormList',
   components: { FormTable, FormNew, FormSort, Loading, PageSection },
-  mixins: [modal()],
   inject: ['alert'],
   setup() {
     // The component does not assume that this data will exist when the
@@ -62,9 +61,7 @@ export default {
   },
   data() {
     return {
-      newForm: {
-        state: false
-      },
+      createModal: modalData(),
       sortMode: 'alphabetical'
     };
   },
diff --git a/src/components/form/settings.vue b/src/components/form/settings.vue
index abbc1b7d4..a4d71fe59 100644
--- a/src/components/form/settings.vue
+++ b/src/components/form/settings.vue
@@ -34,7 +34,7 @@ except according to the terms contained in the LICENSE file.
           <div class="panel-body">
             <p>
               <button type="button" class="btn btn-danger"
-                @click="showModal('deleteForm')">
+                @click="deleteModal.show()">
                 {{ $t('action.delete') }}&hellip;
               </button>
             </p>
@@ -42,7 +42,7 @@ except according to the terms contained in the LICENSE file.
         </div>
       </div>
     </div>
-    <form-delete v-bind="deleteForm" @hide="hideModal('deleteForm')"
+    <form-delete v-bind="deleteModal" @hide="deleteModal.hide()"
       @success="afterDelete"/>
   </div>
 </template>
@@ -50,26 +50,18 @@ except according to the terms contained in the LICENSE file.
 <script>
 import FormDelete from './delete.vue';
 
-import modal from '../../mixins/modal';
 import useRoutes from '../../composables/routes';
+import { modalData } from '../../util/reactivity';
 import { useRequestData } from '../../request-data';
 
 export default {
   name: 'FormSettings',
   components: { FormDelete },
-  mixins: [modal()],
   inject: ['alert'],
   setup() {
     const { form } = useRequestData();
     const { projectPath } = useRoutes();
-    return { form, projectPath };
-  },
-  data() {
-    return {
-      deleteForm: {
-        state: false
-      }
-    };
+    return { form, deleteModal: modalData(), projectPath };
   },
   methods: {
     afterDelete() {
diff --git a/src/components/form/submissions.vue b/src/components/form/submissions.vue
index c660c311b..651d75da2 100644
--- a/src/components/form/submissions.vue
+++ b/src/components/form/submissions.vue
@@ -20,14 +20,14 @@ except according to the terms contained in the LICENSE file.
         </enketo-fill>
         <odata-data-access :analyze-disabled="analyzeDisabled"
           :analyze-disabled-message="analyzeDisabledMessage"
-          @analyze="showModal('analyze')"/>
+          @analyze="analyzeModal.show()"/>
       </template>
       <template #body>
         <submission-list :project-id="projectId" :xml-form-id="xmlFormId" @fetch-keys="fetchData"/>
       </template>
     </page-section>
-    <odata-analyze v-bind="analyze" :odata-url="odataUrl"
-      @hide="hideModal('analyze')"/>
+    <odata-analyze v-bind="analyzeModal" :odata-url="odataUrl"
+      @hide="analyzeModal.hide()"/>
   </div>
 </template>
 
@@ -39,8 +39,8 @@ import OdataAnalyze from '../odata/analyze.vue';
 import OdataDataAccess from '../odata/data-access.vue';
 import SubmissionList from '../submission/list.vue';
 
-import modal from '../../mixins/modal';
 import { apiPaths } from '../../util/request';
+import { modalData } from '../../util/reactivity';
 import { noop } from '../../util/util';
 import { useRequestData } from '../../request-data';
 
@@ -54,7 +54,6 @@ export default {
     OdataDataAccess,
     SubmissionList
   },
-  mixins: [modal()],
   props: {
     projectId: {
       type: String,
@@ -68,14 +67,7 @@ export default {
   setup() {
     const { project, form, createResource } = useRequestData();
     const keys = createResource('keys');
-    return { project, form, keys };
-  },
-  data() {
-    return {
-      analyze: {
-        state: false
-      }
-    };
+    return { project, form, keys, analyzeModal: modalData() };
   },
   computed: {
     rendersEnketoFill() {
diff --git a/src/components/form/trash-list.vue b/src/components/form/trash-list.vue
index c559c20b5..8f4ed0e9c 100644
--- a/src/components/form/trash-list.vue
+++ b/src/components/form/trash-list.vue
@@ -22,10 +22,11 @@ except according to the terms contained in the LICENSE file.
     <table id="form-trash-list-table" class="table">
       <tbody>
         <form-trash-row v-for="form of sortedDeletedForms" :key="form.id" :form="form"
-          @start-restore="showRestore"/>
+          @start-restore="restoreForm.show({ form: $event })"/>
       </tbody>
     </table>
-    <form-restore :state="restoreForm.state" :form="restoreForm.form" @hide="hideRestore" @success="afterRestore"/>
+    <form-restore v-bind="restoreForm" @hide="restoreForm.hide()"
+      @success="afterRestore"/>
   </div>
 </template>
 
@@ -35,30 +36,21 @@ import { ascend, sortWith } from 'ramda';
 import FormTrashRow from './trash-row.vue';
 import FormRestore from './restore.vue';
 
-import modal from '../../mixins/modal';
 import { apiPaths } from '../../util/request';
+import { modalData } from '../../util/reactivity';
 import { noop } from '../../util/util';
 import { useRequestData } from '../../request-data';
 
 export default {
   name: 'FormTrashList',
   components: { FormTrashRow, FormRestore },
-  mixins: [modal()],
   inject: ['alert'],
   emits: ['restore'],
   setup() {
     // The component does not assume that this data will exist when the
     // component is created.
     const { project, deletedForms } = useRequestData();
-    return { project, deletedForms };
-  },
-  data() {
-    return {
-      restoreForm: {
-        state: false,
-        form: null
-      }
-    };
+    return { project, deletedForms, restoreForm: modalData() };
   },
   computed: {
     count() {
@@ -80,17 +72,9 @@ export default {
         resend
       }).catch(noop);
     },
-    showRestore(form) {
-      this.restoreForm.form = form;
-      this.showModal('restoreForm');
-    },
-    hideRestore() {
-      this.hideModal('restoreForm');
-    },
     afterRestore() {
-      this.hideRestore();
       this.alert.success(this.$t('alert.restore', { name: this.restoreForm.form.name }));
-      this.restoreForm.form = null;
+      this.restoreForm.hide();
 
       // refresh trashed forms list
       this.fetchDeletedForms(true);
diff --git a/src/components/project/form-access.vue b/src/components/project/form-access.vue
index 88ecba3de..42020b4d3 100644
--- a/src/components/project/form-access.vue
+++ b/src/components/project/form-access.vue
@@ -35,14 +35,14 @@ except according to the terms contained in the LICENSE file.
       <project-form-access-table :changes-by-form="changesByForm"
         @update:state="updateState"
         @update:field-key-access="updateFieldKeyAccess"
-        @show-states="showModal('statesModal')"/>
+        @show-states="statesModal.show()"/>
       <p v-if="forms.length === 0" class="empty-table-message">
         {{ $t('emptyTable') }}
       </p>
     </template>
 
     <project-form-access-states v-bind="statesModal"
-      @hide="hideModal('statesModal')"/>
+      @hide="statesModal.hide()"/>
   </div>
 </template>
 
@@ -54,9 +54,9 @@ import ProjectFormAccessTable from './form-access/table.vue';
 import SentenceSeparator from '../sentence-separator.vue';
 import Spinner from '../spinner.vue';
 
-import modal from '../../mixins/modal';
 import useRequest from '../../composables/request';
 import { apiPaths } from '../../util/request';
+import { modalData } from '../../util/reactivity';
 import { noop } from '../../util/util';
 import { useRequestData } from '../../request-data';
 
@@ -70,7 +70,6 @@ export default {
     SentenceSeparator,
     Spinner
   },
-  mixins: [modal()],
   inject: ['alert', 'unsavedChanges'],
   props: {
     projectId: {
@@ -92,9 +91,7 @@ export default {
     return {
       changesByForm: null,
       changeCount: 0,
-      statesModal: {
-        state: false
-      }
+      statesModal: modalData()
     };
   },
   computed: {
diff --git a/src/components/project/list.vue b/src/components/project/list.vue
index 760ddfdce..c46f0de0c 100644
--- a/src/components/project/list.vue
+++ b/src/components/project/list.vue
@@ -16,7 +16,7 @@ except according to the terms contained in the LICENSE file.
         <span>{{ $t('resource.projects') }}</span>
         <button v-if="currentUser.can('project.create')"
           id="project-list-new-button" type="button" class="btn btn-primary"
-          @click="showModal('newProject')">
+          @click="createModal.show()">
           <span class="icon-plus-circle"></span>{{ $t('action.create') }}&hellip;
         </button>
         <project-sort v-model="sortMode"/>
@@ -56,7 +56,7 @@ except according to the terms contained in the LICENSE file.
         </div>
       </template>
     </page-section>
-    <project-new v-bind="newProject" @hide="hideModal('newProject')"
+    <project-new v-bind="createModal" @hide="createModal.hide()"
       @success="afterCreate"/>
   </div>
 </template>
@@ -73,12 +73,12 @@ import ProjectHomeBlock from './home-block.vue';
 import ProjectSort from './sort.vue';
 import SentenceSeparator from '../sentence-separator.vue';
 
-import modal from '../../mixins/modal';
 import sortFunctions from '../../util/sort';
 import useChunkyArray from '../../composables/chunky-array';
 import useRoutes from '../../composables/routes';
-import { useRequestData } from '../../request-data';
+import { modalData } from '../../util/reactivity';
 import { sumUnderThreshold } from '../../util/util';
+import { useRequestData } from '../../request-data';
 
 export default {
   name: 'ProjectList',
@@ -91,7 +91,6 @@ export default {
     ProjectSort,
     SentenceSeparator
   },
-  mixins: [modal()],
   inject: ['alert'],
   setup() {
     const { currentUser, projects } = useRequestData();
@@ -113,16 +112,10 @@ export default {
       currentUser, projects,
       sortMode, sortFunction,
       activeProjects, chunkyProjects,
+      createModal: modalData(),
       projectPath
     };
   },
-  data() {
-    return {
-      newProject: {
-        state: false
-      }
-    };
-  },
   computed: {
     archivedProjects() {
       if (!this.projects.dataExists) return [];
diff --git a/src/components/project/settings.vue b/src/components/project/settings.vue
index c381fcf07..2e39fab9c 100644
--- a/src/components/project/settings.vue
+++ b/src/components/project/settings.vue
@@ -30,7 +30,7 @@ except according to the terms contained in the LICENSE file.
               <p>
                 <button id="project-settings-enable-encryption-button"
                   type="button" class="btn btn-primary"
-                  @click="showModal('enableEncryption')">
+                  @click="enableEncryption.show()">
                   {{ $t('encryption.action.enableEncryption') }}&hellip;
                 </button>
               </p>
@@ -57,7 +57,7 @@ except according to the terms contained in the LICENSE file.
             <template v-if="!project.archived">
               <p>
                 <button id="project-settings-archive-button" type="button"
-                  class="btn btn-danger" @click="showModal('archive')">
+                  class="btn btn-danger" @click="archiveModal.show()">
                   {{ $t('dangerZone.action.archive') }}&hellip;
                 </button>
               </p>
@@ -72,8 +72,8 @@ except according to the terms contained in the LICENSE file.
     </div>
 
     <project-enable-encryption v-bind="enableEncryption"
-      @hide="hideModal('enableEncryption')" @success="afterEnableEncryption"/>
-    <project-archive v-bind="archive" @hide="hideModal('archive')"
+      @hide="enableEncryption.hide()" @success="afterEnableEncryption"/>
+    <project-archive v-bind="archiveModal" @hide="archiveModal.hide()"
       @success="afterArchive"/>
   </div>
 </template>
@@ -85,8 +85,8 @@ import ProjectEdit from './edit.vue';
 import ProjectEnableEncryption from './enable-encryption.vue';
 import SentenceSeparator from '../sentence-separator.vue';
 
-import modal from '../../mixins/modal';
 import useRoutes from '../../composables/routes';
+import { modalData } from '../../util/reactivity';
 import { useRequestData } from '../../request-data';
 
 export default {
@@ -98,27 +98,20 @@ export default {
     ProjectEnableEncryption,
     SentenceSeparator
   },
-  mixins: [modal()],
   inject: ['alert'],
   emits: ['fetch-project'],
   setup() {
     const { project } = useRequestData();
     const { projectPath } = useRoutes();
-    return { project, projectPath };
-  },
-  data() {
     return {
-      enableEncryption: {
-        state: false
-      },
-      archive: {
-        state: false
-      }
+      project,
+      enableEncryption: modalData(), archiveModal: modalData(),
+      projectPath
     };
   },
   methods: {
     afterEnableEncryption() {
-      this.hideModal('enableEncryption');
+      this.enableEncryption.hide();
       this.$emit('fetch-project', true);
     },
     afterArchive() {
diff --git a/src/components/public-link/list.vue b/src/components/public-link/list.vue
index 0fc2ee502..55e39e25a 100644
--- a/src/components/public-link/list.vue
+++ b/src/components/public-link/list.vue
@@ -12,8 +12,7 @@ except according to the terms contained in the LICENSE file.
 <template>
   <div id="public-link-list">
     <div class="heading-with-button">
-      <button type="button" class="btn btn-primary"
-        @click="showModal('create')">
+      <button type="button" class="btn btn-primary" @click="createModal.show()">
         <span class="icon-plus-circle"></span>{{ $t('action.create') }}&hellip;
       </button>
       <p>
@@ -31,23 +30,24 @@ except according to the terms contained in the LICENSE file.
       </p>
       <i18n-t tag="p" keypath="heading[1].full">
         <template #clickHere>
-          <a href="#" @click.prevent="showModal('submissionOptions')">{{ $t('heading[1].clickHere') }}</a>
+          <a href="#" @click.prevent="submissionOptions.show()">{{ $t('heading[1].clickHere') }}</a>
         </template>
       </i18n-t>
     </div>
 
-    <public-link-table :highlighted="highlighted" @revoke="showRevoke"/>
+    <public-link-table :highlighted="highlighted"
+      @revoke="revokeModal.show({ publicLink: $event })"/>
     <loading :state="publicLinks.initiallyLoading"/>
     <p v-if="publicLinks.dataExists && publicLinks.length === 0"
       class="empty-table-message">
       {{ $t('emptyTable') }}
     </p>
 
-    <public-link-create v-bind="create" @hide="hideModal('create')"
+    <public-link-create v-bind="createModal" @hide="createModal.hide()"
       @success="afterCreate"/>
     <project-submission-options v-bind="submissionOptions"
-      @hide="hideModal('submissionOptions')"/>
-    <public-link-revoke v-bind="revoke" @hide="hideRevoke"
+      @hide="submissionOptions.hide()"/>
+    <public-link-revoke v-bind="revokeModal" @hide="revokeModal.hide()"
       @success="afterRevoke"/>
   </div>
 </template>
@@ -61,9 +61,9 @@ import PublicLinkRevoke from './revoke.vue';
 import PublicLinkTable from './table.vue';
 import SentenceSeparator from '../sentence-separator.vue';
 
-import modal from '../../mixins/modal';
 import useRoutes from '../../composables/routes';
 import { apiPaths } from '../../util/request';
+import { modalData } from '../../util/reactivity';
 import { noop } from '../../util/util';
 import { useRequestData } from '../../request-data';
 
@@ -78,7 +78,6 @@ export default {
     PublicLinkTable,
     SentenceSeparator
   },
-  mixins: [modal()],
   inject: ['alert'],
   props: {
     projectId: {
@@ -100,16 +99,9 @@ export default {
       // The id of the highlighted public link
       highlighted: null,
       // Modals
-      create: {
-        state: false
-      },
-      submissionOptions: {
-        state: false
-      },
-      revoke: {
-        state: false,
-        publicLink: null
-      }
+      createModal: modalData(),
+      submissionOptions: modalData(),
+      revokeModal: modalData()
     };
   },
   created() {
@@ -123,23 +115,15 @@ export default {
       }).catch(noop);
       this.highlighted = null;
     },
-    showRevoke(publicLink) {
-      this.revoke.publicLink = publicLink;
-      this.showModal('revoke');
-    },
-    hideRevoke() {
-      this.hideModal('revoke');
-      this.revoke.publicLink = null;
-    },
     afterCreate(publicLink) {
       this.fetchData(true);
-      this.hideModal('create');
+      this.createModal.hide();
       this.alert.success(this.$t('alert.create'));
       this.highlighted = publicLink.id;
     },
     afterRevoke(publicLink) {
       this.fetchData(true);
-      this.hideRevoke();
+      this.revokeModal.hide();
       this.alert.success(this.$t('alert.revoke', publicLink));
     }
   }
diff --git a/src/components/submission/activity.vue b/src/components/submission/activity.vue
index 68be7bb9d..8a314e3a5 100644
--- a/src/components/submission/activity.vue
+++ b/src/components/submission/activity.vue
@@ -14,9 +14,8 @@ except according to the terms contained in the LICENSE file.
     <template #heading>
       <span>{{ $t('common.activity') }}</span>
       <template v-if="project.dataExists && project.permits('submission.update')">
-        <button id="submission-activity-update-review-state-button"
-          type="button" class="btn btn-default"
-          @click="$emit('update-review-state')">
+        <button id="submission-activity-review-button" type="button"
+          class="btn btn-default" @click="$emit('review')">
           <span class="icon-check"></span>{{ $t('action.review') }}
         </button>
         <template v-if="submission.dataExists">
@@ -74,7 +73,7 @@ export default {
       required: true
     }
   },
-  emits: ['update-review-state', 'comment'],
+  emits: ['review', 'comment'],
   setup() {
     // The component does not assume that this data will exist when the
     // component is created.
@@ -107,5 +106,5 @@ export default {
 
 <style lang="scss">
 #submission-activity { margin-bottom: 35px; }
-#submission-activity-update-review-state-button { margin-right: 5px; }
+#submission-activity-review-button { margin-right: 5px; }
 </style>
diff --git a/src/components/submission/list.vue b/src/components/submission/list.vue
index e31f97ece..0432598c1 100644
--- a/src/components/submission/list.vue
+++ b/src/components/submission/list.vue
@@ -29,11 +29,12 @@ except according to the terms contained in the LICENSE file.
           </button>
         </form>
         <submission-download-button :form-version="formVersion"
-          :filtered="odataFilter != null" @download="showModal('download')"/>
+          :filtered="odataFilter != null" @download="downloadModal.show()"/>
       </div>
       <submission-table v-show="odata.dataExists && odata.value.length !== 0"
         ref="table" :project-id="projectId" :xml-form-id="xmlFormId"
-        :draft="draft" :fields="selectedFields" @review="showReview"/>
+        :draft="draft" :fields="selectedFields"
+        @review="reviewModal.show({ submission: $event })"/>
       <p v-show="odata.dataExists && odata.value.length === 0"
         class="empty-table-message">
         {{ odataFilter == null ? $t('submission.emptyTable') : $t('noMatching') }}
@@ -46,11 +47,10 @@ except according to the terms contained in the LICENSE file.
         :total-count="formVersion.dataExists ? formVersion.submissions : 0"/>
     </div>
 
-    <submission-download :state="download.state" :form-version="formVersion"
-      :odata-filter="odataFilter" @hide="hideModal('download')"/>
-    <submission-update-review-state :state="review.state"
-      :project-id="projectId" :xml-form-id="xmlFormId"
-      :submission="review.submission" @hide="hideReview"
+    <submission-download v-bind="downloadModal" :form-version="formVersion"
+      :odata-filter="odataFilter" @hide="downloadModal.hide()"/>
+    <submission-update-review-state v-bind="reviewModal" :project-id="projectId"
+      :xml-form-id="xmlFormId" @hide="reviewModal.hide()"
       @success="afterReview"/>
   </div>
 </template>
@@ -69,13 +69,13 @@ import SubmissionFilters from './filters.vue';
 import SubmissionTable from './table.vue';
 import SubmissionUpdateReviewState from './update-review-state.vue';
 
-import modal from '../../mixins/modal';
 import useFields from '../../request-data/fields';
 import useQueryRef from '../../composables/query-ref';
 import useReviewState from '../../composables/review-state';
 import useSubmissions from '../../request-data/submissions';
 import { apiPaths } from '../../util/request';
 import { arrayQuery } from '../../util/router';
+import { modalData } from '../../util/reactivity';
 import { noop } from '../../util/util';
 import { odataLiteral } from '../../util/odata';
 import { useRequestData } from '../../request-data';
@@ -93,7 +93,6 @@ export default {
     SubmissionUpdateReviewState,
     OdataLoadingMessage
   },
-  mixins: [modal()],
   inject: ['alert'],
   props: {
     projectId: {
@@ -185,13 +184,9 @@ export default {
       // among the options.)
       selectedFields: shallowRef(null),
       refreshing: false,
-      download: {
-        state: false
-      },
-      review: {
-        state: false,
-        submission: null
-      }
+      // Modals
+      downloadModal: modalData(),
+      reviewModal: modalData()
     };
   },
   computed: {
@@ -315,19 +310,11 @@ export default {
         !this.odata.awaitingResponse && this.scrolledToBottom())
         this.fetchChunk(false);
     },
-    showReview(submission) {
-      this.review.submission = submission;
-      this.showModal('review');
-    },
-    hideReview() {
-      this.hideModal('review');
-      this.review.submission = null;
-    },
     // This method accounts for the unlikely case that the user clicked the
     // refresh button before reviewing the submission. In that case, the
     // submission may have been edited or may no longer be shown.
     afterReview(originalSubmission, reviewState) {
-      this.hideReview();
+      this.reviewModal.hide();
       this.alert.success(this.$t('alert.updateReviewState'));
       const index = this.odata.value.findIndex(submission =>
         submission.__id === originalSubmission.__id);
diff --git a/src/components/submission/show.vue b/src/components/submission/show.vue
index 45e869c8b..b34123233 100644
--- a/src/components/submission/show.vue
+++ b/src/components/submission/show.vue
@@ -26,15 +26,14 @@ except according to the terms contained in the LICENSE file.
         </div>
         <div class="col-xs-8">
           <submission-activity :project-id="projectId" :xml-form-id="xmlFormId"
-            :instance-id="instanceId"
-            @update-review-state="showModal('updateReviewState')"
+            :instance-id="instanceId" @review="reviewModal.show()"
             @comment="fetchActivityData"/>
         </div>
       </div>
     </page-body>
-    <submission-update-review-state :state="updateReviewState.state"
-      :project-id="projectId" :xml-form-id="xmlFormId" :submission="submission"
-      @hide="hideModal('updateReviewState')" @success="afterUpdateReviewState"/>
+    <submission-update-review-state v-bind="reviewModal" :project-id="projectId"
+      :xml-form-id="xmlFormId" :submission="submission"
+      @hide="reviewModal.hide()" @success="afterReview"/>
   </div>
 </template>
 
@@ -49,12 +48,11 @@ import SubmissionActivity from './activity.vue';
 import SubmissionBasicDetails from './basic-details.vue';
 import SubmissionUpdateReviewState from './update-review-state.vue';
 
-import modal from '../../mixins/modal';
 import useFields from '../../request-data/fields';
 import useRoutes from '../../composables/routes';
 import useSubmission from '../../request-data/submission';
 import { apiPaths } from '../../util/request';
-import { setDocumentTitle } from '../../util/reactivity';
+import { modalData, setDocumentTitle } from '../../util/reactivity';
 import { useRequestData } from '../../request-data';
 
 export default {
@@ -68,7 +66,6 @@ export default {
     SubmissionBasicDetails,
     SubmissionUpdateReviewState
   },
-  mixins: [modal()],
   inject: ['alert'],
   props: {
     projectId: {
@@ -98,16 +95,10 @@ export default {
     return {
       project, submission, submissionVersion, audits, comments, diffs, fields,
       ...resourceStates([project, submission]),
+      reviewModal: modalData(),
       formPath
     };
   },
-  data() {
-    return {
-      updateReviewState: {
-        state: false
-      }
-    };
-  },
   created() {
     this.fetchData();
   },
@@ -170,9 +161,9 @@ export default {
       ]);
       this.fetchActivityData();
     },
-    afterUpdateReviewState(submission, reviewState) {
+    afterReview(submission, reviewState) {
       this.fetchActivityData();
-      this.hideModal('updateReviewState');
+      this.reviewModal.hide();
       this.alert.success(this.$t('alert.updateReviewState'));
       this.submission.__system.reviewState = reviewState;
     }
diff --git a/src/components/user/list.vue b/src/components/user/list.vue
index 80b6286d9..ba2ffdda5 100644
--- a/src/components/user/list.vue
+++ b/src/components/user/list.vue
@@ -17,7 +17,7 @@ either is an Administrator or has no role. -->
   <div>
     <div class="heading-with-button">
       <button id="user-list-new-button" type="button" class="btn btn-primary"
-        @click="showModal('newUser')">
+        @click="createModal.show()">
         <span class="icon-plus-circle"></span>{{ $t('action.create') }}&hellip;
       </button>
       <i18n-t tag="p" keypath="heading[0]">
@@ -38,17 +38,19 @@ either is an Administrator or has no role. -->
       <tbody v-if="dataExists">
         <user-row v-for="user of users" :key="user.id" :user="user"
           :admin="adminIds.has(user.id)" :highlighted="highlighted"
-          @assigned-role="afterAssignRole" @reset-password="showResetPassword"
-          @retire="showRetire"/>
+          @assigned-role="afterAssignRole"
+          @reset-password="resetPassword.show({ user: $event })"
+          @retire="retireModal.show({ user: $event })"/>
       </tbody>
     </table>
     <loading :state="initiallyLoading"/>
 
-    <user-new v-bind="newUser" @hide="hideModal('newUser')"
+    <user-new v-bind="createModal" @hide="createModal.hide()"
       @success="afterCreate"/>
-    <user-reset-password v-if="!config.oidcEnabled" v-bind="resetPassword" @hide="hideResetPassword"
-      @success="afterResetPassword"/>
-    <user-retire v-bind="retire" @hide="hideRetire" @success="afterRetire"/>
+    <user-reset-password v-if="!config.oidcEnabled" v-bind="resetPassword"
+      @hide="resetPassword.hide()" @success="afterResetPassword"/>
+    <user-retire v-bind="retireModal" @hide="retireModal.hide()"
+      @success="afterRetire"/>
   </div>
 </template>
 
@@ -60,7 +62,7 @@ import UserResetPassword from './reset-password.vue';
 import UserRetire from './retire.vue';
 import UserRow from './row.vue';
 
-import modal from '../../mixins/modal';
+import { modalData } from '../../util/reactivity';
 import { useRequestData } from '../../request-data';
 
 export default {
@@ -73,7 +75,6 @@ export default {
     UserRetire,
     UserRow
   },
-  mixins: [modal()],
   inject: ['alert', 'config'],
   setup() {
     const { createResource, resourceStates } = useRequestData();
@@ -89,17 +90,9 @@ export default {
       // The id of the highlighted user
       highlighted: null,
       // Modals
-      newUser: {
-        state: false
-      },
-      resetPassword: {
-        state: false,
-        user: null
-      },
-      retire: {
-        state: false,
-        user: null
-      }
+      createModal: modalData(),
+      resetPassword: modalData(),
+      retireModal: modalData()
     };
   },
   created() {
@@ -114,7 +107,7 @@ export default {
     },
     afterCreate(user) {
       this.fetchData();
-      this.hideModal('newUser');
+      this.createModal.hide();
       this.alert.success(this.$t('alert.create', user));
       this.highlighted = user.id;
     },
@@ -148,30 +141,13 @@ export default {
         }
       }
     },
-    showResetPassword(user) {
-      this.resetPassword.user = user;
-      this.showModal('resetPassword');
-    },
-    hideResetPassword() {
-      this.hideModal('resetPassword');
-      this.resetPassword.user = null;
-    },
     afterResetPassword(user) {
-      this.hideResetPassword();
-      this.hideModal('resetPassword');
+      this.resetPassword.hide();
       this.alert.success(this.$t('alert.resetPassword', user));
     },
-    showRetire(user) {
-      this.retire.user = user;
-      this.showModal('retire');
-    },
-    hideRetire() {
-      this.hideModal('retire');
-      this.retire.user = null;
-    },
     afterRetire(user) {
       this.fetchData();
-      this.hideRetire();
+      this.retireModal.hide();
       this.alert.success(this.$t('alert.retire', user));
       this.highlighted = null;
     }
diff --git a/src/mixins/modal.js b/src/mixins/modal.js
deleted file mode 100644
index 2ed162d01..000000000
--- a/src/mixins/modal.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
-Copyright 2017 ODK Central Developers
-See the NOTICE file at the top-level directory of this distribution and at
-https://github.com/getodk/central-frontend/blob/master/NOTICE.
-
-This file is part of ODK Central. It is subject to the license terms in
-the LICENSE file found in the top-level directory of this distribution and at
-https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
-including this file, may be copied, modified, propagated, or distributed
-except according to the terms contained in the LICENSE file.
-*/
-
-/*
-This mixin is deprecated. Use modalData() instead.
-
-A component that contains one or more modals may use this mixin, which includes
-methods for toggling a modal.
-
-The component using this mixin must define a data property for each modal that
-it contains. The property should be an object that has a property named `state`
-that indicates whether the modal should be shown.
-*/
-
-// @vue/component
-const mixin = {
-  methods: {
-    showModal(name) {
-      this[name].state = true;
-    },
-    hideModal(name) {
-      this[name].state = false;
-    }
-  }
-};
-
-export default () => mixin;
diff --git a/test/components/submission/activity.spec.js b/test/components/submission/activity.spec.js
index 10d025bb7..e97bcb4b3 100644
--- a/test/components/submission/activity.spec.js
+++ b/test/components/submission/activity.spec.js
@@ -62,7 +62,7 @@ describe('SubmissionActivity', () => {
       return load('/projects/1/forms/f/submissions/s', { root: false })
         .testModalToggles({
           modal: SubmissionUpdateReviewState,
-          show: '#submission-activity-update-review-state-button',
+          show: '#submission-activity-review-button',
           hide: '.btn-link'
         });
     });
@@ -73,7 +73,7 @@ describe('SubmissionActivity', () => {
       testData.extendedSubmissions.createPast(1);
       testData.extendedAudits.createPast(1, { action: 'submission.create' });
       const component = mountComponent();
-      component.find('#submission-activity-update-review-state-button').exists().should.be.false();
+      component.find('#submission-activity-review-button').exists().should.be.false();
     });
 
     describe('after a successful response', () => {
@@ -88,7 +88,7 @@ describe('SubmissionActivity', () => {
         return load('/projects/1/forms/a%20b/submissions/c%20d', { root: false })
           .complete()
           .request(async (component) => {
-            const button = component.get('#submission-activity-update-review-state-button');
+            const button = component.get('#submission-activity-review-button');
             await button.trigger('click');
             const modal = component.getComponent(SubmissionUpdateReviewState);
             await modal.get('input[value="hasIssues"]').setValue(true);