diff --git a/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift b/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift
index 122fe918d1a2178ae726f0dd9a703bd2f1fd931c..d9b7d23306b64b6924d5ce7ff3351728667476a3 100644
--- a/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift
+++ b/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift
@@ -1,29 +1,40 @@
 import AppCore
 import ComposableArchitecture
 import Foundation
+import XXMessengerClient
 import XXModels
 
 public struct GroupComponent: ReducerProtocol {
   public struct State: Equatable {
     public init(
       groupId: XXModels.Group.ID,
-      groupInfo: XXModels.GroupInfo? = nil
+      groupInfo: XXModels.GroupInfo? = nil,
+      isJoining: Bool = false,
+      joinFailure: String? = nil
     ) {
       self.groupId = groupId
       self.groupInfo = groupInfo
+      self.isJoining = isJoining
+      self.joinFailure = joinFailure
     }
 
     public var groupId: XXModels.Group.ID
     public var groupInfo: XXModels.GroupInfo?
+    public var isJoining: Bool
+    public var joinFailure: String?
   }
 
   public enum Action: Equatable {
     case start
     case didFetchGroupInfo(XXModels.GroupInfo?)
+    case joinButtonTapped
+    case didJoin
+    case didFailToJoin(String)
   }
 
   public init() {}
 
+  @Dependency(\.app.messenger) var messenger: Messenger
   @Dependency(\.app.dbManager.getDB) var db: DBManagerGetDB
   @Dependency(\.app.mainQueue) var mainQueue: AnySchedulerOf<DispatchQueue>
   @Dependency(\.app.bgQueue) var bgQueue: AnySchedulerOf<DispatchQueue>
@@ -47,6 +58,36 @@ public struct GroupComponent: ReducerProtocol {
       case .didFetchGroupInfo(let groupInfo):
         state.groupInfo = groupInfo
         return .none
+
+      case .joinButtonTapped:
+        guard let info = state.groupInfo else { return .none }
+        state.isJoining = true
+        state.joinFailure = nil
+        return Effect.result {
+          do {
+            let groupChat = try messenger.groupChat.tryGet()
+            try groupChat.joinGroup(serializedGroupData: info.group.serialized)
+            var group = info.group
+            group.authStatus = .participating
+            try db().saveGroup(group)
+            return .success(.didJoin)
+          } catch {
+            return .success(.didFailToJoin(error.localizedDescription))
+          }
+        }
+        .subscribe(on: bgQueue)
+        .receive(on: mainQueue)
+        .eraseToEffect()
+
+      case .didJoin:
+        state.isJoining = false
+        state.joinFailure = nil
+        return .none
+
+      case .didFailToJoin(let failure):
+        state.isJoining = false
+        state.joinFailure = failure
+        return .none
       }
     }
   }
diff --git a/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift b/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift
index 2cd55078a2afada5489e144c75668e996398796d..67b880f4b1ad3f8e790f51f1202624f195d0d60a 100644
--- a/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift
+++ b/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift
@@ -16,9 +16,13 @@ public struct GroupView: View {
   struct ViewState: Equatable {
     init(state: Component.State) {
       info = state.groupInfo
+      isJoining = state.isJoining
+      joinFailure = state.joinFailure
     }
 
     var info: XXModels.GroupInfo?
+    var isJoining: Bool
+    var joinFailure: String?
   }
 
   public var body: some View {
@@ -41,6 +45,27 @@ public struct GroupView: View {
 
           Section("Status") {
             GroupAuthStatusView(info.group.authStatus)
+
+            if case .pending = info.group.authStatus {
+              Button {
+                viewStore.send(.joinButtonTapped)
+              } label: {
+                HStack {
+                  Text("Join")
+                  Spacer()
+                  if viewStore.isJoining {
+                    ProgressView()
+                  } else {
+                    Image(systemName: "play.fill")
+                  }
+                }
+              }
+              .disabled(viewStore.isJoining)
+            }
+
+            if let failure = viewStore.joinFailure {
+              Text(failure)
+            }
           }
         }
       }
diff --git a/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift b/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift
index 3522ab7cae63566db7062ea1df601418ddc1552e..3c7193fc4ebea418f97800fae37e782d982d497f 100644
--- a/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift
+++ b/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift
@@ -2,12 +2,16 @@ import Combine
 import ComposableArchitecture
 import CustomDump
 import XCTest
+import XXClient
+import XXMessengerClient
 import XXModels
 @testable import GroupFeature
 
 final class GroupComponentTests: XCTestCase {
   enum Action: Equatable {
     case didFetchGroupInfos(GroupInfo.Query)
+    case didJoinGroup(Data)
+    case didSaveGroup(XXModels.Group)
   }
 
   var actions: [Action]!
@@ -57,6 +61,85 @@ final class GroupComponentTests: XCTestCase {
 
     groupInfosSubject.send(completion: .finished)
   }
+
+  func testJoinGroup() {
+    var groupInfo = GroupInfo.stub()
+    groupInfo.group.authStatus = .pending
+
+    let store = TestStore(
+      initialState: GroupComponent.State(
+        groupId: groupInfo.group.id,
+        groupInfo: groupInfo
+      ),
+      reducer: GroupComponent()
+    )
+
+    store.dependencies.app.mainQueue = .immediate
+    store.dependencies.app.bgQueue = .immediate
+    store.dependencies.app.messenger.groupChat.get = {
+      var groupChat: GroupChat = .unimplemented
+      groupChat.joinGroup.run = { serializedGroupData in
+        self.actions.append(.didJoinGroup(serializedGroupData))
+      }
+      return groupChat
+    }
+    store.dependencies.app.dbManager.getDB.run = {
+      var db: Database = .unimplemented
+      db.saveGroup.run = { group in
+        self.actions.append(.didSaveGroup(group))
+        return group
+      }
+      return db
+    }
+
+    store.send(.joinButtonTapped) {
+      $0.isJoining = true
+    }
+
+    XCTAssertNoDifference(actions, [
+      .didJoinGroup(groupInfo.group.serialized),
+      .didSaveGroup({
+        var group = groupInfo.group
+        group.authStatus = .participating
+        return group
+      }())
+    ])
+
+    store.receive(.didJoin) {
+      $0.isJoining = false
+    }
+  }
+
+  func testJoinGroupFailure() {
+    let groupInfo = GroupInfo.stub()
+    struct Failure: Error {}
+    let failure = Failure()
+
+    let store = TestStore(
+      initialState: GroupComponent.State(
+        groupId: groupInfo.group.id,
+        groupInfo: groupInfo
+      ),
+      reducer: GroupComponent()
+    )
+
+    store.dependencies.app.mainQueue = .immediate
+    store.dependencies.app.bgQueue = .immediate
+    store.dependencies.app.messenger.groupChat.get = {
+      var groupChat: GroupChat = .unimplemented
+      groupChat.joinGroup.run = { _ in throw failure }
+      return groupChat
+    }
+
+    store.send(.joinButtonTapped) {
+      $0.isJoining = true
+    }
+
+    store.receive(.didFailToJoin(failure.localizedDescription)) {
+      $0.isJoining = false
+      $0.joinFailure = failure.localizedDescription
+    }
+  }
 }
 
 private extension XXModels.GroupInfo {