Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,37 +757,37 @@ Text(
)
```

If there are multiple *States*, use the `.render()` method for the target *State*.
If there are multiple *States*, use the `.stateContent()` method for the target *State*.

```kt
viewStore.render<CounterState.Main> {
viewStore.stateContent<CounterState.Main> {
Text(
text = state.count.toString(),
)
}
```

When drawing the UI, if it does not match the target *State*, the `.render()` will not be executed.
When drawing the UI, if it does not match the target *State*, the `.stateContent()` will not be executed.
Therefore, you can define components for each *State* side by side.

```kt
viewStore.render<CounterState.Loading> {
viewStore.stateContent<CounterState.Loading> {
Text(
text = "loading..",
)
}

viewStore.render<CounterState.Main> {
viewStore.stateContent<CounterState.Main> {
Text(
text = state.count.toString(),
)
}
```

If you use lower components in the `render()` block, pass its instance.
If you use lower components in the `stateContent()` block, pass its instance.

```kt
viewStore.render<CounterState.Main> {
viewStore.stateContent<CounterState.Main> {
YourComposable(
viewStore = this, // ViewStore instance for CounterState.Main
)
Expand Down Expand Up @@ -822,18 +822,18 @@ Button(

### Handling Events

Use ViewStore's `.handle()` with the target *Event*.
Use ViewStore's `.eventEffect()` with the target *Event*.

```kt
viewStore.handle<CounterEvent.ShowToast> { event ->
viewStore.eventEffect<CounterEvent.ShowToast> { event ->
// do something..
}
```

In the above example, you can also subscribe to the parent *Event* type.

```kt
viewStore.handle<CounterEvent> { event ->
viewStore.eventEffect<CounterEvent> { event ->
when (event) {
is CounterEvent.ShowToast -> // do something..
is CounterEvent.GoBack -> // do something..
Expand Down
7 changes: 6 additions & 1 deletion doc/internal/adr/2026-04-30-viewstore-compose-naming.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `ViewStore.render` / `handle` の PascalCase 置き換えは採用しない

- 更新日: 2026-04-30
- 更新日: 2026-05-25

## 背景

Expand Down Expand Up @@ -29,3 +29,8 @@
- そのため今回は「Compose guideline への整合」より、「`ViewStore` DSL としての自然さ」を優先する。
- 既存 API には `@Suppress("ComposableNaming")` が必要だが、このコストは上記の不自然さを受け入れるより小さいと判断する。
- 将来、トップレベルでもメンバでもない、より自然な API 形が見つかった場合はあらためて検討してよい。

## 2026-05-25 追記

その後、API 名は `render` / `handle` から `stateContent` / `eventEffect` へリネームされた。
この rename により、API の意味は Compose 文脈により沿うようになったが、member composable を PascalCase にするかという論点自体は変わっていない。
2 changes: 1 addition & 1 deletion doc/internal/notes/2026-04-23-store-start-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ fun startPolicy(policy: StoreStartPolicy)
- 未採用候補として `EAGER` も考えられる。これは「`dispatch()` や `state` の collect を待たず、Store 作成直後に start する」という意味になる。v1 では見送るのがよい。`attachObserver()` が start 前のみ許可という現行前提と衝突しやすい。
- `ON_FIRST_DISPATCH` では、`state` を collect しても start しない。その場合でも `StateFlow` としては current snapshot を読めるので、UI が「現在値の監視」と「副作用の開始」を分離しやすくなる。
- start 直後に `enter { event(...) }` のような one-shot event を出したい場合、現状の default では `state` の collect が先に start trigger になり、あとから `event` を購読した側が初回 event を見逃しうる。
- この問題は Compose の `rememberViewStore()` でも起こりうる。`rememberViewStore()` は内部で `store.state.collectAsState()` を行う一方、event の collect は `ViewStore.handle()` 側の `LaunchedEffect` で始まるため、同じ画面内で両方を書いていても event collector が start 前に間に合う保証はない。
- この問題は Compose の `rememberViewStore()` でも起こりうる。`rememberViewStore()` は内部で `store.state.collectAsState()` を行う一方、event の collect は `ViewStore.eventEffect()` 側の `LaunchedEffect` で始まるため、同じ画面内で両方を書いていても event collector が start 前に間に合う保証はない。
- この問題に対しては start policy が有効な回避策になりうる。`ON_FIRST_DISPATCH` なら `state` / `event` の購読を張ったあとに明示的な最初の `dispatch()` で start でき、`MANUAL` なら購読を張ったあとに `start()` を呼ぶ順序をさらに明示しやすい。
- ただし start policy が解決するのは「購読前に start しない」ことまでであり、`Store.event` 自体の replay semantics は変えない。start 後に購読した側へ過去 event を再配送するわけではない。
- `MANUAL` で start 前に `dispatch()` された場合は、暗黙 start や silently ignore ではなく、例外で失敗させる方がバグを早く見つけやすい。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,26 @@ class ViewStore<S : State, A : Action, E : Event> internal constructor(
*
* Inside [block], this [ViewStore] is narrowed to [S2].
*
* @param block Composable function to render the narrowed state
* @param block Composable function to render content for the narrowed state
*/
@Suppress("ComposableNaming")
@Composable
inline fun <reified S2 : S> render(block: @Composable ViewStore<S2, A, E>.() -> Unit) {
inline fun <reified S2 : S> stateContent(block: @Composable ViewStore<S2, A, E>.() -> Unit) {
if (state is S2) {
@Suppress("UNCHECKED_CAST")
block(this as ViewStore<S2, A, E>)
}
}

@Deprecated(
message = "Use stateContent instead.",
replaceWith = ReplaceWith("stateContent<S2>(block)"),
level = DeprecationLevel.WARNING,
)
@Suppress("ComposableNaming")
@Composable
inline fun <reified S2 : S> render(block: @Composable ViewStore<S2, A, E>.() -> Unit) = stateContent<S2>(block)

/**
* Collects only events of type [E2] while this composable is in the composition.
*
Expand All @@ -80,7 +89,7 @@ class ViewStore<S : State, A : Action, E : Event> internal constructor(
*/
@Suppress("ComposableNaming")
@Composable
inline fun <reified E2 : E> handle(noinline block: ViewStore<S, A, E>.(event: E2) -> Unit) {
inline fun <reified E2 : E> eventEffect(noinline block: ViewStore<S, A, E>.(event: E2) -> Unit) {
val currentViewStore = rememberUpdatedState(this)
val currentBlock = rememberUpdatedState(block)
LaunchedEffect(eventFlow) {
Expand All @@ -89,15 +98,24 @@ class ViewStore<S : State, A : Action, E : Event> internal constructor(
}
}
}

@Deprecated(
message = "Use eventEffect instead.",
replaceWith = ReplaceWith("eventEffect<E2>(block)"),
level = DeprecationLevel.WARNING,
)
@Suppress("ComposableNaming")
@Composable
inline fun <reified E2 : E> handle(noinline block: ViewStore<S, A, E>.(event: E2) -> Unit) = eventEffect<E2>(block)
}

/**
* Remembers a [Store], collects its state as Compose state, and exposes it through a [ViewStore].
*
* Collecting [Store.state] starts the Store immediately.
* Because [ViewStore.handle] starts collecting later from a [LaunchedEffect], startup events such
* as events emitted from an initial `enter {}` handler may be emitted before handlers in the same
* composition begin observing them.
* Because [ViewStore.eventEffect] starts collecting later from a [LaunchedEffect], startup events
* such as events emitted from an initial `enter {}` handler may be emitted before handlers in the
* same composition begin observing them.
*
* The [store] lambda is used only when a new remembered Store must be created for [key].
* For a given remembered Store instance, this function returns the same [ViewStore] instance across
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,18 @@ class ViewStoreJvmTest {
private val testDispatcher = UnconfinedTestDispatcher()

@Test
fun render_callsBlockOnlyForMatchingState() = runTest(testDispatcher) {
fun stateContent_callsBlockOnlyForMatchingState() = runTest(testDispatcher) {
val renderedValues = mutableListOf<Int>()

withComposition(
content = {
ViewStore<UiState, Nothing, Nothing>(state = UiState.Ready(10))
.render<UiState.Ready> {
.stateContent<UiState.Ready> {
renderedValues += state.value
}

ViewStore<UiState, Nothing, Nothing>(state = UiState.Loading)
.render<UiState.Ready> {
.stateContent<UiState.Ready> {
renderedValues += state.value
}
},
Expand All @@ -53,7 +53,7 @@ class ViewStoreJvmTest {
}

@Test
fun handle_collectsOnlySpecifiedEventType() = runTest(testDispatcher) {
fun eventEffect_collectsOnlySpecifiedEventType() = runTest(testDispatcher) {
val events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 4)
val handled = mutableListOf<UiEvent.ValueChanged>()

Expand All @@ -62,7 +62,7 @@ class ViewStoreJvmTest {
ViewStore<UiState, UiAction, UiEvent>(
state = UiState.Ready(0),
eventFlow = events,
).handle<UiEvent.ValueChanged> { event ->
).eventEffect<UiEvent.ValueChanged> { event ->
handled += event
}
},
Expand All @@ -76,7 +76,7 @@ class ViewStoreJvmTest {
}

@Test
fun handle_usesLatestViewStoreAndLambdaAfterRecomposition() = runTest(testDispatcher) {
fun eventEffect_usesLatestViewStoreAndLambdaAfterRecomposition() = runTest(testDispatcher) {
val events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 4)
val handled = mutableListOf<String>()
var label = "initial"
Expand All @@ -99,7 +99,7 @@ class ViewStoreJvmTest {
ViewStore<UiState, UiAction, UiEvent>(
state = viewState,
eventFlow = events,
).handle<UiEvent.ValueChanged> { event ->
).eventEffect<UiEvent.ValueChanged> { event ->
handled += "${(state as UiState.Ready).value}:$label:${event.value}"
}
}
Expand All @@ -111,7 +111,7 @@ class ViewStoreJvmTest {
ViewStore<UiState, UiAction, UiEvent>(
state = viewState,
eventFlow = events,
).handle<UiEvent.ValueChanged> { event ->
).eventEffect<UiEvent.ValueChanged> { event ->
handled += "${(state as UiState.Ready).value}:$label:${event.value}"
}
}
Expand All @@ -129,6 +129,46 @@ class ViewStoreJvmTest {
assertEquals(listOf("2:updated:42"), handled)
}

@Suppress("DEPRECATION")
@Test
fun render_delegatesToStateContent() = runTest(testDispatcher) {
val renderedValues = mutableListOf<Int>()

withComposition(
content = {
ViewStore<UiState, Nothing, Nothing>(state = UiState.Ready(7))
.render<UiState.Ready> {
renderedValues += state.value
}
},
)

assertEquals(listOf(7), renderedValues)
}

@Suppress("DEPRECATION")
@Test
fun handle_delegatesToEventEffect() = runTest(testDispatcher) {
val events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 4)
val handled = mutableListOf<UiEvent.ValueChanged>()

withComposition(
content = {
ViewStore<UiState, UiAction, UiEvent>(
state = UiState.Ready(0),
eventFlow = events,
).handle<UiEvent.ValueChanged> { event ->
handled += event
}
},
afterSetContent = {
assertTrue(events.tryEmit(UiEvent.ValueChanged(100)))
},
)

assertEquals(listOf(UiEvent.ValueChanged(100)), handled)
}

@Test
fun rememberViewStore_providesCurrentStateAndDispatchesAction() = runTest(testDispatcher) {
val store = TestStore(UiState.Ready(1))
Expand Down
Loading