diff --git a/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt b/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt
index f73704ab017af6361d83e63f6975e198cbf254e1..4bd50fd4ca961fa27fae2857d1f4d115c9cbee53 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt
@@ -29,6 +29,7 @@ import org.briarproject.bramble.util.OsUtils.isLinux
 import org.briarproject.bramble.util.OsUtils.isMac
 import org.briarproject.briar.desktop.ui.BriarUi
 import org.briarproject.briar.desktop.ui.BriarUiImpl
+import org.briarproject.briar.desktop.viewmodel.ViewModelModule
 import java.io.File
 import java.nio.file.Path
 import java.util.Collections.emptyList
@@ -46,7 +47,8 @@ import javax.inject.Singleton
         DesktopSecureRandomModule::class,
         JavaNetworkModule::class,
         JavaSystemModule::class,
-        SocksModule::class
+        SocksModule::class,
+        ViewModelModule::class,
     ]
 )
 internal class DesktopModule(
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactInfoDrawer.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactInfoDrawer.kt
index f43736a975bd38b7427f2e45fc0ba9782d447131..a0732821932654c0afdad88ad942abafb77c1d13 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactInfoDrawer.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactInfoDrawer.kt
@@ -1,9 +1,9 @@
 package org.briarproject.briar.desktop.contact
 
 import androidx.compose.runtime.Composable
+import org.briarproject.bramble.api.contact.Contact
 import org.briarproject.briar.desktop.contact.ContactInfoDrawerState.MakeIntro
 import org.briarproject.briar.desktop.introduction.ContactDrawerMakeIntro
-import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 
 // Right drawer state
 enum class ContactInfoDrawerState {
@@ -14,11 +14,11 @@ enum class ContactInfoDrawerState {
 
 @Composable
 fun ContactInfoDrawer(
-    introductionViewModel: IntroductionViewModel,
+    contact: Contact,
     setInfoDrawer: (Boolean) -> Unit,
     drawerState: ContactInfoDrawerState
 ) {
     when (drawerState) {
-        MakeIntro -> ContactDrawerMakeIntro(introductionViewModel, setInfoDrawer)
+        MakeIntro -> ContactDrawerMakeIntro(contact, setInfoDrawer)
     }
 }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
index 06913697cf7cf93bbf2f75173a4bd20b0883fbcc..16b1f0a3bc25f02e4b428488ca36781b67e1af3a 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
@@ -16,19 +16,22 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.contact.ContactId
 import org.briarproject.briar.desktop.contact.add.remote.AddContactDialog
-import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
 import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants.CONTACTLIST_WIDTH
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 
 @Composable
 fun ContactList(
-    viewModel: ContactListViewModel,
-    addContactViewModel: AddContactViewModel,
+    contactList: List<ContactItem>,
+    isSelected: (ContactId) -> Boolean,
+    selectContact: (ContactId) -> Unit,
+    filterBy: String,
+    setFilterBy: (String) -> Unit,
 ) {
     var isContactDialogVisible by remember { mutableStateOf(false) }
-    if (isContactDialogVisible) AddContactDialog(addContactViewModel) { isContactDialogVisible = false }
+    if (isContactDialogVisible) AddContactDialog(onClose = { isContactDialogVisible = false })
     Scaffold(
         modifier = Modifier.fillMaxHeight().width(CONTACTLIST_WIDTH),
         backgroundColor = MaterialTheme.colors.surfaceVariant,
@@ -37,19 +40,19 @@ fun ContactList(
                 modifier = Modifier.fillMaxWidth().height(HEADER_SIZE + 1.dp),
             ) {
                 SearchTextField(
-                    viewModel.filterBy.value,
-                    onValueChange = viewModel::setFilterBy,
+                    filterBy,
+                    onValueChange = setFilterBy,
                     onContactAdd = { isContactDialogVisible = true }
                 )
             }
         },
         content = {
             LazyColumn {
-                items(viewModel.contactList) { contactItem ->
+                items(contactList) { contactItem ->
                     ContactCard(
                         contactItem,
-                        { viewModel.selectContact(contactItem.contact.id) },
-                        viewModel.isSelected(contactItem.contact.id)
+                        { selectContact(contactItem.contact.id) },
+                        isSelected(contactItem.contact.id)
                     )
                 }
             }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
index 30e3cc04df2c931dad9dfab31341e701d982c435..0617d92f2fa23e1431699f51bfa6a30a337ff1cb 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
@@ -22,15 +22,21 @@ constructor(
     conversationManager: ConversationManager,
     connectionRegistry: ConnectionRegistry,
     eventBus: EventBus,
-) : ContactsViewModel(contactManager, conversationManager, connectionRegistry) {
+) : ContactsViewModel(contactManager, conversationManager, connectionRegistry, eventBus) {
 
     companion object {
         private val LOG = KotlinLogging.logger {}
     }
 
-    init {
-        // todo: where/when to remove listener again?
-        eventBus.addListener(this)
+    override fun onInit() {
+        super.onInit()
+        loadContacts()
+    }
+
+    override fun onCleared() {
+        super.onCleared()
+        // todo: also reset filterBy?
+        _selectedContactId.value = null
     }
 
     private val _filterBy = mutableStateOf("")
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
index 4bb2e4a7a4584626339d769f162c241aa2fb0b03..157e028f680982d1a629373808fd3768355971d0 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
@@ -9,18 +9,20 @@ import org.briarproject.bramble.api.contact.ContactManager
 import org.briarproject.bramble.api.contact.event.ContactAddedEvent
 import org.briarproject.bramble.api.contact.event.ContactRemovedEvent
 import org.briarproject.bramble.api.event.Event
-import org.briarproject.bramble.api.event.EventListener
+import org.briarproject.bramble.api.event.EventBus
 import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent
 import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent
 import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.desktop.utils.removeFirst
 import org.briarproject.briar.desktop.utils.replaceFirst
+import org.briarproject.briar.desktop.viewmodel.BriarEventListenerViewModel
 
 abstract class ContactsViewModel(
     protected val contactManager: ContactManager,
     private val conversationManager: ConversationManager,
-    private val connectionRegistry: ConnectionRegistry
-) : EventListener {
+    private val connectionRegistry: ConnectionRegistry,
+    eventBus: EventBus,
+) : BriarEventListenerViewModel(eventBus) {
 
     companion object {
         private val LOG = KotlinLogging.logger {}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
index db2f6dc5062a28c140c4fd422398423a66bdf596..c66906e7ae9096d63fbd4613fb542979ef4e8a06 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
@@ -15,19 +15,18 @@ import androidx.compose.material.Text
 import androidx.compose.material.TextButton
 import androidx.compose.material.TextField
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
+import org.briarproject.briar.desktop.viewmodel.viewModel
 
 @OptIn(ExperimentalMaterialApi::class)
 @Composable
-fun AddContactDialog(viewModel: AddContactViewModel, onClose: () -> Unit) {
-    LaunchedEffect("fetchHandshake") {
-        // todo: should instead be done automatically as soon as DB is loaded -> in view model
-        viewModel.fetchHandshakeLink()
-    }
+fun AddContactDialog(
+    onClose: () -> Unit,
+    viewModel: AddContactViewModel = viewModel(),
+) {
     AlertDialog(
         onDismissRequest = onClose,
         text = {
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt
index a429601f5c37614000419c9cd1e39fb8e1312f5a..7d9fe3fcd1ec5bafea7a07ea067189c5d1070d8f 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt
@@ -9,6 +9,7 @@ import org.briarproject.bramble.api.db.ContactExistsException
 import org.briarproject.bramble.api.db.PendingContactExistsException
 import org.briarproject.bramble.api.identity.AuthorConstants
 import org.briarproject.bramble.util.StringUtils
+import org.briarproject.briar.desktop.viewmodel.ViewModel
 import java.security.GeneralSecurityException
 import javax.inject.Inject
 
@@ -16,7 +17,11 @@ class AddContactViewModel
 @Inject
 constructor(
     private val contactManager: ContactManager,
-) {
+) : ViewModel {
+
+    override fun onInit() {
+        fetchHandshakeLink()
+    }
 
     private val _alias = mutableStateOf("")
     private val _remoteHandshakeLink = mutableStateOf("")
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt
similarity index 83%
rename from src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt
rename to src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt
index dee4283f89c6ef0877e195600cb87168b5b0abda..97604a38d77f6725c8ae7de446b663cca6a40a2a 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt
@@ -31,23 +31,22 @@ import androidx.compose.ui.unit.dp
 import org.briarproject.bramble.api.contact.ContactId
 import org.briarproject.briar.desktop.contact.ContactInfoDrawer
 import org.briarproject.briar.desktop.contact.ContactInfoDrawerState
-import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 import org.briarproject.briar.desktop.navigation.SIDEBAR_WIDTH
 import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants.CONTACTLIST_WIDTH
 import org.briarproject.briar.desktop.ui.Loader
+import org.briarproject.briar.desktop.viewmodel.viewModel
 
 @Composable
-fun Conversation(
+fun ConversationScreen(
     contactId: ContactId,
-    conversationViewModel: ConversationViewModel,
-    introductionViewModel: IntroductionViewModel,
+    viewModel: ConversationViewModel = viewModel(),
 ) {
     LaunchedEffect(contactId) {
-        conversationViewModel.setContactId(contactId)
+        viewModel.setContactId(contactId)
     }
 
-    val contactItem = conversationViewModel.contactItem.value
+    val contactItem = viewModel.contactItem.value
 
     if (contactItem == null) {
         Loader()
@@ -63,10 +62,6 @@ fun Conversation(
                 ConversationHeader(
                     contactItem,
                     onMakeIntroduction = {
-                        introductionViewModel.apply {
-                            setFirstContact(contactItem.contact)
-                            loadContacts()
-                        }
                         setInfoDrawer(true)
                     }
                 )
@@ -79,7 +74,7 @@ fun Conversation(
                     contentPadding = PaddingValues(8.dp),
                     modifier = Modifier.padding(padding).fillMaxHeight()
                 ) {
-                    items(conversationViewModel.messages) { m ->
+                    items(viewModel.messages) { m ->
                         if (m is ConversationMessageItem)
                             TextBubble(m)
                     }
@@ -87,9 +82,9 @@ fun Conversation(
             },
             bottomBar = {
                 ConversationInput(
-                    conversationViewModel.newMessage.value,
-                    conversationViewModel::setNewMessage,
-                    conversationViewModel::sendMessage
+                    viewModel.newMessage.value,
+                    viewModel::setNewMessage,
+                    viewModel::sendMessage
                 )
             },
         )
@@ -113,7 +108,7 @@ fun Conversation(
                         RoundedCornerShape(topStart = 10.dp, bottomStart = 10.dp)
                     )
             ) {
-                ContactInfoDrawer(introductionViewModel, setInfoDrawer, contactDrawerState)
+                ContactInfoDrawer(contactItem.contact, setInfoDrawer, contactDrawerState)
             }
         }
     }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
index a1c864d2019ed1eb56ac53d6262e2c96a4d6e25d..97d7a96a76dda3289434f992b35b3030a4c926fa 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
@@ -13,7 +13,6 @@ import org.briarproject.bramble.api.db.DbException
 import org.briarproject.bramble.api.db.NoSuchContactException
 import org.briarproject.bramble.api.event.Event
 import org.briarproject.bramble.api.event.EventBus
-import org.briarproject.bramble.api.event.EventListener
 import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent
 import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent
 import org.briarproject.bramble.api.sync.MessageId
@@ -32,6 +31,7 @@ import org.briarproject.briar.api.messaging.PrivateMessageHeader
 import org.briarproject.briar.desktop.contact.ContactItem
 import org.briarproject.briar.desktop.utils.KLoggerUtils.logDuration
 import org.briarproject.briar.desktop.utils.replaceIf
+import org.briarproject.briar.desktop.viewmodel.BriarEventListenerViewModel
 import java.util.Date
 import javax.inject.Inject
 
@@ -42,19 +42,14 @@ constructor(
     private val contactManager: ContactManager,
     private val conversationManager: ConversationManager,
     private val messagingManager: MessagingManager,
-    private val eventBus: EventBus,
     private val privateMessageFactory: PrivateMessageFactory,
-) : EventListener {
+    private val eventBus: EventBus,
+) : BriarEventListenerViewModel(eventBus) {
 
     companion object {
         private val LOG = KotlinLogging.logger {}
     }
 
-    init {
-        // todo: where/when to remove listener again?
-        eventBus.addListener(this)
-    }
-
     private val _contactId = mutableStateOf<ContactId?>(null)
     private val _contactItem = mutableStateOf<ContactItem?>(null)
     private val _messages = mutableStateListOf<ConversationItem>()
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt
similarity index 59%
rename from src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt
rename to src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt
index 655237c74c32986887dda65b241a95b822a5ad6a..3fc3d795352aa9be3c7f80294c4a992c1ddefcad 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt
@@ -8,25 +8,27 @@ import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import org.briarproject.briar.desktop.contact.ContactList
 import org.briarproject.briar.desktop.contact.ContactListViewModel
-import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
-import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 import org.briarproject.briar.desktop.ui.UiPlaceholder
 import org.briarproject.briar.desktop.ui.VerticalDivider
+import org.briarproject.briar.desktop.viewmodel.viewModel
 
 @Composable
-fun PrivateMessageView(
-    contactListViewModel: ContactListViewModel,
-    conversationViewModel: ConversationViewModel,
-    addContactViewModel: AddContactViewModel,
-    introductionViewModel: IntroductionViewModel,
+fun PrivateMessageScreen(
+    viewModel: ContactListViewModel = viewModel(),
 ) {
     Row(modifier = Modifier.fillMaxWidth()) {
-        ContactList(contactListViewModel, addContactViewModel)
+        ContactList(
+            viewModel.contactList,
+            viewModel::isSelected,
+            viewModel::selectContact,
+            viewModel.filterBy.value,
+            viewModel::setFilterBy
+        )
         VerticalDivider()
         Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
-            val id = contactListViewModel.selectedContactId.value
+            val id = viewModel.selectedContactId.value
             if (id != null) {
-                Conversation(id, conversationViewModel, introductionViewModel)
+                ConversationScreen(id)
             } else {
                 UiPlaceholder()
             }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt b/src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt
index 8716c44ef85401d10401dcdc55c33f028588690e..c10f6f4519bc0922cf8f5f9065809440158a6e67 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt
@@ -20,24 +20,31 @@ import androidx.compose.material.icons.filled.ArrowBack
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.SwapHoriz
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
+import org.briarproject.bramble.api.contact.Contact
 import org.briarproject.briar.desktop.contact.ContactCard
 import org.briarproject.briar.desktop.contact.ProfileCircle
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 import org.briarproject.briar.desktop.ui.HorizontalDivider
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF
+import org.briarproject.briar.desktop.viewmodel.viewModel
 import java.util.Locale
 
 @Composable
 fun ContactDrawerMakeIntro(
-    viewModel: IntroductionViewModel,
-    setInfoDrawer: (Boolean) -> Unit
+    contact: Contact,
+    setInfoDrawer: (Boolean) -> Unit,
+    viewModel: IntroductionViewModel = viewModel(),
 ) {
+    LaunchedEffect(contact) {
+        viewModel.setFirstContact(contact)
+    }
     if (!viewModel.secondScreen.value) {
         Surface {
             Column {
@@ -49,7 +56,7 @@ fun ContactDrawerMakeIntro(
                         Icon(Icons.Filled.Close, i18n("access.introduction.close"))
                     }
                     Text(
-                        text = i18nF("introduction.title_first", viewModel.firstContact.value!!.author.name),
+                        text = i18nF("introduction.title_first", contact.author.name),
                         fontSize = 16.sp,
                         modifier = Modifier.align(Alignment.CenterVertically)
                     )
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
index 6c9481af60ddd72cd810a5fe147ebe1e9ddcddbb..e8b2e63bfdfba6959305f49b6c85c6b3f1830001 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
@@ -17,12 +17,7 @@ constructor(
     conversationManager: ConversationManager,
     connectionRegistry: ConnectionRegistry,
     eventBus: EventBus,
-) : ContactsViewModel(contactManager, conversationManager, connectionRegistry) {
-
-    init {
-        // todo: where/when to remove listener again?
-        eventBus.addListener(this)
-    }
+) : ContactsViewModel(contactManager, conversationManager, connectionRegistry, eventBus) {
 
     private val _firstContact = mutableStateOf<Contact?>(null)
     private val _secondContact = mutableStateOf<Contact?>(null)
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/login/Login.kt b/src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt
similarity index 90%
rename from src/main/kotlin/org/briarproject/briar/desktop/login/Login.kt
rename to src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt
index bfc6ff0d8360769bfcc78f6b71961fc0b5758333..c3f072c1fdcef4968e7b86108a9cfd77136635b4 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/login/Login.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt
@@ -16,7 +16,7 @@ import androidx.compose.material.OutlinedTextField
 import androidx.compose.material.Surface
 import androidx.compose.material.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.ExperimentalComposeUiApi
@@ -35,25 +35,23 @@ import androidx.compose.ui.text.input.KeyboardType
 import androidx.compose.ui.text.input.PasswordVisualTransformation
 import androidx.compose.ui.unit.dp
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.viewmodel.viewModel
 
 // TODO: Error handling
 @OptIn(ExperimentalComposeUiApi::class)
 @Composable
-fun Login(
-    viewModel: LoginViewModel,
-    modifier: Modifier = Modifier,
-    onSignedIn: () -> Unit
+fun LoginScreen(
+    onSignedIn: () -> Unit,
+    viewModel: LoginViewModel = viewModel(),
 ) {
     val signIn = {
-        viewModel.signIn {
-            onSignedIn()
-        }
+        viewModel.signIn(onSignedIn)
     }
 
     val initialFocusRequester = remember { FocusRequester() }
     Surface {
         Column(
-            modifier = modifier.padding(16.dp).fillMaxSize(),
+            modifier = Modifier.padding(16.dp).fillMaxSize(),
             verticalArrangement = Arrangement.Center,
             horizontalAlignment = Alignment.CenterHorizontally
         ) {
@@ -81,9 +79,8 @@ fun Login(
                 Text(i18n("login.login"))
             }
 
-            DisposableEffect(Unit) {
+            LaunchedEffect(true) {
                 initialFocusRequester.requestFocus()
-                onDispose { }
             }
         }
     }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/login/LoginViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/login/LoginViewModel.kt
index a86e37f42534991545ca1340499d7ceb96547855..85d939c109aba3be7e47078f0e626dbb3ea56605 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/login/LoginViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/login/LoginViewModel.kt
@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
 import org.briarproject.bramble.api.account.AccountManager
 import org.briarproject.bramble.api.crypto.DecryptionException
 import org.briarproject.bramble.api.lifecycle.LifecycleManager
+import org.briarproject.briar.desktop.viewmodel.ViewModel
 import javax.inject.Inject
 
 class LoginViewModel
@@ -12,7 +13,7 @@ class LoginViewModel
 constructor(
     private val accountManager: AccountManager,
     private val lifecycleManager: LifecycleManager,
-) {
+) : ViewModel {
 
     private val _password = mutableStateOf("")
 
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/login/Registration.kt b/src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationScreen.kt
similarity index 93%
rename from src/main/kotlin/org/briarproject/briar/desktop/login/Registration.kt
rename to src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationScreen.kt
index e90a0d6c1f378e23c39fa21aeb9db3b165c96c1d..7e3251ad844ba725c5079638d39c313117f587c4 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/login/Registration.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationScreen.kt
@@ -34,27 +34,23 @@ import androidx.compose.ui.text.input.KeyboardType
 import androidx.compose.ui.text.input.PasswordVisualTransformation
 import androidx.compose.ui.unit.dp
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.viewmodel.viewModel
 import java.util.Locale
 
 // TODO: Error handling and password strength
 @OptIn(ExperimentalComposeUiApi::class)
 @Composable
-fun Registration(
-    viewModel: RegistrationViewModel,
-    modifier: Modifier = Modifier,
-    onSignedUp: () -> Unit
+fun RegistrationScreen(
+    onSignedUp: () -> Unit,
+    viewModel: RegistrationViewModel = viewModel(),
 ) {
-    val signUp = {
-        viewModel.signUp {
-            onSignedUp()
-        }
-    }
+    val signUp = { viewModel.signUp(onSignedUp) }
 
     val initialFocusRequester = remember { FocusRequester() }
     val focusManager = LocalFocusManager.current
     Surface {
         Column(
-            modifier = modifier.padding(16.dp).fillMaxSize(),
+            modifier = Modifier.padding(16.dp).fillMaxSize(),
             verticalArrangement = Arrangement.Center,
             horizontalAlignment = Alignment.CenterHorizontally
         ) {
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationViewModel.kt
index 6a133b40d2e7bf54030140735869042633e57227..b36dffd9fdf63a1cb7504a37324229963a50b090 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationViewModel.kt
@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
 import org.briarproject.bramble.api.account.AccountManager
 import org.briarproject.bramble.api.crypto.PasswordStrengthEstimator
 import org.briarproject.bramble.api.lifecycle.LifecycleManager
+import org.briarproject.briar.desktop.viewmodel.ViewModel
 import javax.inject.Inject
 
 class RegistrationViewModel
@@ -13,7 +14,7 @@ constructor(
     private val accountManager: AccountManager,
     private val lifecycleManager: LifecycleManager,
     private val passwordStrengthEstimator: PasswordStrengthEstimator,
-) {
+) : ViewModel {
 
     private var isSafeEnough = mutableStateOf(false)
     private val _username = mutableStateOf("")
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/navigation/BriarSidebar.kt b/src/main/kotlin/org/briarproject/briar/desktop/navigation/BriarSidebar.kt
index 023bb259c15940254515a723e39a59f4231f5958..6bdbae821bf38e4260ae063e607d5187eac4d5e4 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/navigation/BriarSidebar.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/navigation/BriarSidebar.kt
@@ -19,11 +19,11 @@ import androidx.compose.material.icons.filled.Logout
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.filled.WifiTethering
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.identity.LocalAuthor
 import org.briarproject.briar.desktop.contact.ProfileCircle
 import org.briarproject.briar.desktop.theme.sidebarSurface
 import org.briarproject.briar.desktop.ui.UiMode
@@ -32,10 +32,17 @@ val SIDEBAR_WIDTH = 56.dp
 
 @Composable
 fun BriarSidebar(
-    viewModel: SidebarViewModel,
+    account: LocalAuthor?,
+    uiMode: UiMode,
+    setUiMode: (UiMode) -> Unit,
 ) {
-    LaunchedEffect(true) {
-        viewModel.loadAccountInfo()
+    val displayButton = @Composable { selectedMode: UiMode, mode: UiMode, icon: ImageVector ->
+        BriarSidebarButton(
+            selectedMode == mode,
+            { setUiMode(mode) },
+            icon,
+            mode.toString()
+        )
     }
 
     Surface(modifier = Modifier.width(SIDEBAR_WIDTH).fillMaxHeight(), color = MaterialTheme.colors.sidebarSurface) {
@@ -45,39 +52,29 @@ fun BriarSidebar(
                 modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 5.dp, bottom = 4.dp),
                 onClick = {}
             ) {
-                viewModel.account.value?.let { ProfileCircle(size = 45.dp, it.id.bytes) }
+                account?.let { ProfileCircle(size = 45.dp, it.id.bytes) }
             }
 
             for (
-                (uiMode, icon) in listOf(
+                (mode, icon) in listOf(
                     Pair(UiMode.CONTACTS, Icons.Filled.Contacts),
                     Pair(UiMode.GROUPS, Icons.Filled.Group),
                     Pair(UiMode.FORUMS, Icons.Filled.Forum),
                     Pair(UiMode.BLOGS, Icons.Filled.ChromeReaderMode),
                 )
             ) {
-                BriarSidebarButton(
-                    viewModel.uiMode.value == uiMode,
-                    { viewModel.setUiMode(uiMode) },
-                    icon,
-                    uiMode.toString()
-                )
+                displayButton(uiMode, mode, icon)
             }
         }
         Column(verticalArrangement = Arrangement.Bottom) {
             for (
-                (uiMode, icon) in listOf(
+                (mode, icon) in listOf(
                     Pair(UiMode.TRANSPORTS, Icons.Filled.WifiTethering),
                     Pair(UiMode.SETTINGS, Icons.Filled.Settings),
                     Pair(UiMode.SIGNOUT, Icons.Filled.Logout),
                 )
             ) {
-                BriarSidebarButton(
-                    viewModel.uiMode.value == uiMode,
-                    { viewModel.setUiMode(uiMode) },
-                    icon,
-                    uiMode.toString()
-                )
+                displayButton(uiMode, mode, icon)
             }
         }
     }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/navigation/SidebarViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/navigation/SidebarViewModel.kt
index e7b560ce95b67ad6b0cb615635e7ba0362de4a86..414e17709b0a5d4b06cc2806901569b3cd84854e 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/navigation/SidebarViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/navigation/SidebarViewModel.kt
@@ -5,13 +5,18 @@ import androidx.compose.runtime.mutableStateOf
 import org.briarproject.bramble.api.identity.IdentityManager
 import org.briarproject.bramble.api.identity.LocalAuthor
 import org.briarproject.briar.desktop.ui.UiMode
+import org.briarproject.briar.desktop.viewmodel.ViewModel
 import javax.inject.Inject
 
 class SidebarViewModel
 @Inject
 constructor(
     private val identityManager: IdentityManager,
-) {
+) : ViewModel {
+
+    override fun onInit() {
+        loadAccountInfo()
+    }
 
     private var _uiMode = mutableStateOf(UiMode.CONTACTS)
     private var _account = mutableStateOf<LocalAuthor?>(null)
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
index 7d0005487abee625fd4a7c204a2d99cdf0f3bf3d..3d9ad8ddfc789fd8e3697f042804af4cb1fabfaa 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
@@ -1,26 +1,22 @@
 package org.briarproject.briar.desktop.ui
 
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
+import androidx.compose.runtime.staticCompositionLocalOf
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.window.Window
 import org.briarproject.bramble.api.account.AccountManager
 import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.bramble.api.lifecycle.LifecycleManager.LifecycleState.RUNNING
-import org.briarproject.briar.desktop.contact.ContactListViewModel
-import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
-import org.briarproject.briar.desktop.conversation.ConversationViewModel
-import org.briarproject.briar.desktop.introduction.IntroductionViewModel
-import org.briarproject.briar.desktop.login.Login
-import org.briarproject.briar.desktop.login.LoginViewModel
-import org.briarproject.briar.desktop.login.Registration
-import org.briarproject.briar.desktop.login.RegistrationViewModel
-import org.briarproject.briar.desktop.navigation.SidebarViewModel
+import org.briarproject.briar.desktop.login.LoginScreen
+import org.briarproject.briar.desktop.login.RegistrationScreen
 import org.briarproject.briar.desktop.theme.BriarTheme
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.viewmodel.ViewModelProvider
 import java.awt.Dimension
 import javax.annotation.concurrent.Immutable
 import javax.inject.Inject
@@ -40,20 +36,16 @@ interface BriarUi {
     fun stop()
 }
 
+val LocalViewModelProvider = staticCompositionLocalOf<ViewModelProvider?> { null }
+
 @Immutable
 @Singleton
 internal class BriarUiImpl
 @Inject
 constructor(
-    private val registrationViewModel: RegistrationViewModel,
-    private val loginViewModel: LoginViewModel,
-    private val contactListViewModel: ContactListViewModel,
-    private val conversationViewModel: ConversationViewModel,
-    private val addContactViewModel: AddContactViewModel,
-    private val introductionViewModel: IntroductionViewModel,
-    private val sidebarViewModel: SidebarViewModel,
     private val accountManager: AccountManager,
     private val lifecycleManager: LifecycleManager,
+    private val viewModelProvider: ViewModelProvider,
 ) : BriarUi {
 
     override fun stop() {
@@ -73,7 +65,6 @@ constructor(
                 if (accountManager.hasDatabaseKey()) {
                     // this should only happen during testing when we launch the main UI directly
                     // without a need to enter the password.
-                    contactListViewModel.loadContacts()
                     Screen.MAIN
                 } else if (accountManager.accountExists()) {
                     Screen.LOGIN
@@ -88,28 +79,24 @@ constructor(
             icon = painterResource("images/logo_circle.svg")
         ) {
             window.minimumSize = Dimension(800, 600)
-            BriarTheme(isDarkTheme = isDark) {
-                when (screenState) {
-                    Screen.REGISTRATION ->
-                        Registration(registrationViewModel) {
-                            contactListViewModel.loadContacts()
-                            screenState = Screen.MAIN
-                        }
-                    Screen.LOGIN ->
-                        Login(loginViewModel) {
-                            contactListViewModel.loadContacts()
-                            screenState = Screen.MAIN
-                        }
-                    else ->
-                        MainScreen(
-                            contactListViewModel,
-                            conversationViewModel,
-                            addContactViewModel,
-                            introductionViewModel,
-                            sidebarViewModel,
-                            isDark,
-                            setDark
-                        )
+            CompositionLocalProvider(LocalViewModelProvider provides viewModelProvider) {
+                BriarTheme(isDarkTheme = isDark) {
+                    when (screenState) {
+                        Screen.REGISTRATION ->
+                            RegistrationScreen(
+                                onSignedUp = {
+                                    screenState = Screen.MAIN
+                                }
+                            )
+                        Screen.LOGIN ->
+                            LoginScreen(
+                                onSignedIn = {
+                                    screenState = Screen.MAIN
+                                }
+                            )
+                        else ->
+                            MainScreen(isDark, setDark)
+                    }
                 }
             }
         }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
index e6f11c87dc9ac664921810bb8c125d39f8ab4339..c636fa7384a9510090c94353db27b744fc954ce5 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
@@ -2,14 +2,11 @@ package org.briarproject.briar.desktop.ui
 
 import androidx.compose.foundation.layout.Row
 import androidx.compose.runtime.Composable
-import org.briarproject.briar.desktop.contact.ContactListViewModel
-import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
-import org.briarproject.briar.desktop.conversation.ConversationViewModel
-import org.briarproject.briar.desktop.conversation.PrivateMessageView
-import org.briarproject.briar.desktop.introduction.IntroductionViewModel
+import org.briarproject.briar.desktop.conversation.PrivateMessageScreen
 import org.briarproject.briar.desktop.navigation.BriarSidebar
 import org.briarproject.briar.desktop.navigation.SidebarViewModel
 import org.briarproject.briar.desktop.settings.PlaceHolderSettingsView
+import org.briarproject.briar.desktop.viewmodel.viewModel
 
 /*
  * This is the root of the tree, all state is held here and passed down to stateless composables, which render the UI
@@ -18,24 +15,19 @@ import org.briarproject.briar.desktop.settings.PlaceHolderSettingsView
  */
 @Composable
 fun MainScreen(
-    contactListViewModel: ContactListViewModel,
-    conversationViewModel: ConversationViewModel,
-    addContactViewModel: AddContactViewModel,
-    introductionViewModel: IntroductionViewModel,
-    sidebarViewModel: SidebarViewModel,
     isDark: Boolean,
-    setDark: (Boolean) -> Unit
+    setDark: (Boolean) -> Unit,
+    viewModel: SidebarViewModel = viewModel(),
 ) {
     Row {
-        BriarSidebar(sidebarViewModel)
+        BriarSidebar(
+            viewModel.account.value,
+            viewModel.uiMode.value,
+            viewModel::setUiMode
+        )
         VerticalDivider()
-        when (sidebarViewModel.uiMode.value) {
-            UiMode.CONTACTS -> PrivateMessageView(
-                contactListViewModel,
-                conversationViewModel,
-                addContactViewModel,
-                introductionViewModel
-            )
+        when (viewModel.uiMode.value) {
+            UiMode.CONTACTS -> PrivateMessageScreen()
             UiMode.SETTINGS -> PlaceHolderSettingsView(isDark, setDark)
             else -> UiPlaceholder()
         }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/BriarEventListenerViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/BriarEventListenerViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7c7b2908fd97c9d4e399959c0d2bf21874f4ac00
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/BriarEventListenerViewModel.kt
@@ -0,0 +1,17 @@
+package org.briarproject.briar.desktop.viewmodel
+
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.event.EventListener
+
+abstract class BriarEventListenerViewModel(
+    private val eventBus: EventBus
+) : ViewModel, EventListener {
+
+    override fun onInit() {
+        eventBus.addListener(this)
+    }
+
+    override fun onCleared() {
+        eventBus.removeListener(this)
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ComposeUtils.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ComposeUtils.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4957735fe98f0868dac97b401bcb1ee8d4395567
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ComposeUtils.kt
@@ -0,0 +1,65 @@
+package org.briarproject.briar.desktop.viewmodel
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import org.briarproject.briar.desktop.ui.LocalViewModelProvider
+import kotlin.reflect.KClass
+
+/**
+ * Returns an existing [ViewModel] or creates a new one
+ *
+ * The [ViewModel] is created and retained in the given [viewModelProvider],
+ * that defaults to [LocalViewModelProvider].
+ * It will be automatically initialized as soon as the calling screen is composed
+ * for the first time, and cleared when it goes out of scope.
+ *
+ * @param viewModelProvider The scope that the created [ViewModel] should be associated with.
+ * @param key The key to use to identify the [ViewModel].
+ * @return A [ViewModel] that is an instance of the given [VM] type.
+ */
+@Composable
+inline fun <reified VM : ViewModel> viewModel(
+    key: String? = null,
+    viewModelProvider: ViewModelProvider = checkNotNull(LocalViewModelProvider.current) {
+        "No ViewModelProvider was provided via LocalViewModelProvider"
+    }
+): VM = viewModel(VM::class, key, viewModelProvider)
+
+/**
+ * Returns an existing [ViewModel] or creates a new one
+ *
+ * The [ViewModel] is created and retained in the given [viewModelProvider],
+ * that defaults to [LocalViewModelProvider].
+ * It will be automatically initialized as soon as the calling screen is composed
+ * for the first time, and cleared when it goes out of scope.
+ *
+ * @param modelClass The class of the [ViewModel] to create an instance of it if it is not
+ * present.
+ * @param viewModelProvider The scope that the created [ViewModel] should be associated with.
+ * @param key The key to use to identify the [ViewModel].
+ * @return A [ViewModel] that is an instance of the given [VM] type.
+ */
+@Composable
+fun <VM : ViewModel> viewModel(
+    modelClass: KClass<VM>,
+    key: String? = null,
+    viewModelProvider: ViewModelProvider = checkNotNull(LocalViewModelProvider.current) {
+        "No ViewModelProvider was provided via LocalViewModelProvider"
+    }
+): VM {
+    val viewModel = if (key == null) {
+        viewModelProvider.get(modelClass)
+    } else {
+        viewModelProvider.get(key, modelClass)
+    }
+
+    DisposableEffect(key) {
+        viewModel.onInit()
+
+        onDispose {
+            viewModel.onCleared()
+        }
+    }
+
+    return viewModel
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c5934859cd9f35ae843df0be0cf6d2eb57bd2794
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModel.kt
@@ -0,0 +1,16 @@
+package org.briarproject.briar.desktop.viewmodel
+
+interface ViewModel {
+
+    /**
+     * Called to initialize the [ViewModel] as soon as it is first used
+     * inside a Composable function.
+     */
+    fun onInit() {}
+
+    /**
+     * Called to clear the [ViewModel] as soon as the calling
+     * Composable function goes out of scope.
+     */
+    fun onCleared() {}
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelFactory.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelFactory.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c3f519db25805ae5da80f0dafb73bfb0604170ab
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelFactory.kt
@@ -0,0 +1,27 @@
+package org.briarproject.briar.desktop.viewmodel
+
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+import kotlin.reflect.KClass
+
+@Singleton
+class ViewModelFactory
+@Inject
+constructor(
+    private val creators: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
+) {
+    fun <VM : ViewModel> create(modelClass: KClass<VM>): VM {
+        var creator = creators[modelClass.java]
+        if (creator == null) {
+            for ((key, value) in creators) {
+                if (modelClass.java.isAssignableFrom(key)) {
+                    creator = value
+                    break
+                }
+            }
+        }
+        requireNotNull(creator) { "unknown model class $modelClass" }
+        return creator.get() as VM
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a1edb2acb4a579258a2691fd054505ebac214ab4
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
@@ -0,0 +1,55 @@
+package org.briarproject.briar.desktop.viewmodel
+
+import dagger.Binds
+import dagger.MapKey
+import dagger.Module
+import dagger.multibindings.IntoMap
+import org.briarproject.briar.desktop.contact.ContactListViewModel
+import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
+import org.briarproject.briar.desktop.conversation.ConversationViewModel
+import org.briarproject.briar.desktop.introduction.IntroductionViewModel
+import org.briarproject.briar.desktop.login.LoginViewModel
+import org.briarproject.briar.desktop.login.RegistrationViewModel
+import org.briarproject.briar.desktop.navigation.SidebarViewModel
+import kotlin.reflect.KClass
+
+@Module
+abstract class ViewModelModule {
+    @MapKey
+    internal annotation class ViewModelKey(val value: KClass<out ViewModel>)
+
+    @Binds
+    @IntoMap
+    @ViewModelKey(LoginViewModel::class)
+    abstract fun bindLoginViewModel(loginViewModel: LoginViewModel): ViewModel
+
+    @Binds
+    @IntoMap
+    @ViewModelKey(RegistrationViewModel::class)
+    abstract fun bindRegistrationViewModel(registrationViewModel: RegistrationViewModel): ViewModel
+
+    @Binds
+    @IntoMap
+    @ViewModelKey(SidebarViewModel::class)
+    abstract fun bindSidebarViewModel(sidebarViewModel: SidebarViewModel): ViewModel
+
+    @Binds
+    @IntoMap
+    @ViewModelKey(ContactListViewModel::class)
+    abstract fun bindContactListViewModel(contactListViewModel: ContactListViewModel): ViewModel
+
+    @Binds
+    @IntoMap
+    @ViewModelKey(AddContactViewModel::class)
+    abstract fun bindAddContactViewModel(addContactViewModel: AddContactViewModel): ViewModel
+
+    @Binds
+    @IntoMap
+    @ViewModelKey(ConversationViewModel::class)
+    abstract fun bindConversationViewModel(conversationViewModel: ConversationViewModel): ViewModel
+
+    @Binds
+    @IntoMap
+    @ViewModelKey(IntroductionViewModel::class)
+    abstract fun bindIntroductionViewModel(introductionViewModel: IntroductionViewModel): ViewModel
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelProvider.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelProvider.kt
new file mode 100644
index 0000000000000000000000000000000000000000..83f59d104de4d1e75b10cb282d6349e0834436d6
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelProvider.kt
@@ -0,0 +1,34 @@
+package org.briarproject.briar.desktop.viewmodel
+
+import javax.inject.Inject
+import kotlin.reflect.KClass
+
+class ViewModelProvider
+@Inject
+constructor(
+    private val viewModelFactory: ViewModelFactory
+) {
+
+    private val viewModels = HashMap<String, ViewModel>()
+
+    fun <VM : ViewModel> get(modelClass: KClass<VM>): VM =
+        get(modelClass.qualifiedName!!, modelClass)
+
+    fun <VM : ViewModel> get(key: String, modelClass: KClass<VM>): VM {
+        val viewModel = viewModels[key]
+
+        if (modelClass.isInstance(viewModel)) {
+            return viewModel as VM
+        }
+
+        try {
+            val viewModel = viewModelFactory.create(modelClass)
+            viewModels[key] = viewModel
+            return viewModel
+        } catch (e: InstantiationException) {
+            throw RuntimeException("Cannot create an instance of $modelClass", e)
+        } catch (e: IllegalAccessException) {
+            throw RuntimeException("Cannot create an instance of $modelClass", e)
+        }
+    }
+}
diff --git a/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt b/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt
index 7bc4eff5f61ea7c5e39e8f8563dffdd00489e8fe..cad4b989717dd9430d352a158b39542f4a37ad97 100644
--- a/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt
+++ b/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt
@@ -32,6 +32,7 @@ import org.briarproject.briar.desktop.testdata.DeterministicTestDataCreator
 import org.briarproject.briar.desktop.testdata.DeterministicTestDataCreatorImpl
 import org.briarproject.briar.desktop.ui.BriarUi
 import org.briarproject.briar.desktop.ui.BriarUiImpl
+import org.briarproject.briar.desktop.viewmodel.ViewModelModule
 import org.briarproject.briar.test.TestModule
 import java.io.File
 import java.nio.file.Path
@@ -52,6 +53,7 @@ import javax.inject.Singleton
         JavaSystemModule::class,
         SocksModule::class,
         TestModule::class,
+        ViewModelModule::class
     ]
 )
 internal class DesktopTestModule(