Emitter UI refresh failure issue

Read the original article:Emitter UI refresh failure issue

Problem Description

When using the emitter, subscription and unsubscription are encapsulated inside a class and called through new instances. After subscribing, when the emitter event is triggered and parameters change, the UI does not refresh as expected.

Background Knowledge

  • Emitter: Event handling mechanism in HarmonyOS for intra-thread or inter-process communication.
    • Subscription: Register callbacks to listen for events.
    • Publishing: Trigger events to notify subscribers.
    • Unsubscription: Stop listening to free resources.
  • Use cases: Useful for event handling across threads or when UI needs to react to state changes.

  • State management in ArkTS: Properties must be wrapped by state decorators (@State, @ObservedV2, @Trace) or reassigned after constructor execution to trigger UI updates.

Troubleshooting Process

  1. Checked whether the this context inside arrow functions is correctly bound.
  2. Verified whether state variables are being tracked by the ArkTS reactive system.
  3. Tested different subscription styles (emitter.on(event, callback) vs inline arrow).
  4. Observed that number1 and number2 were updated, but since they weren’t properly proxied by state decorators, UI didn’t refresh.

Analysis Conclusion

  • Arrow functions assigned during constructor execution capture raw values before state proxying by @Provide.
  • This results in updates happening on plain objects, bypassing state management → UI does not refresh.

Solution

Option 1: Re-assign the arrow function after constructor execution

Define a method to reassign the arrow function and call it in aboutToAppear.

import { emitter } from '@kit.BasicServicesKit'

export class BaseA {
  static MESSAGE_ONE = "1"
  static MESSAGE_TWO = "2"
  number1: number = 0
  number2: number = 0

  private callback1 = () => {
    this.number1 += 1
  }

  public registerCallback1() {
    this.callback1 = () => {
      this.number1 += 1
    }
  }

  register1() {
    emitter.on(BaseA.MESSAGE_ONE, this.callback1)
  }

  register2() {
    emitter.on(BaseA.MESSAGE_TWO, () => this.callback2())
  }

  private callback2() {
    this.number2 += 1
  }
}

@Entry
@Component
struct EmitterTestPage {
  @Provide viewModel: BaseA = new BaseA()

  aboutToAppear(): void {
    this.viewModel.registerCallback1()
  }

  build() {
    Column({ space: 10 }) {
      Text("callback1: " + this.viewModel.number1.toString())
        .fontSize(18).fontColor('#f00').margin({ top: 20 })
      Text("callback2: " + this.viewModel.number2.toString())
        .fontSize(18).fontColor('#0f0').margin({ top: 20 })
      Button('Send message, update directly').onClick(() => {
        this.viewModel.number1 += 1
        this.viewModel.number2 += 1
      })
      Button('Subscribe in ViewModel (method 1)').onClick(() => {
        this.viewModel.register1()
      })
      Button('Subscribe in ViewModel (method 2)').onClick(() => {
        this.viewModel.register2()
      })
      Button('Send message, update MessageOne').onClick(() => {
        emitter.emit(BaseA.MESSAGE_ONE)
      })
      Button('Send message, update MessageTwo').onClick(() => {
        emitter.emit(BaseA.MESSAGE_TWO)
      })
    }.margin({ top: 20 })
  }
}

Option 2: Use @ObservedV2 with @trace

Wrap properties with @Trace to ensure UI refreshes when values change.

import { emitter } from '@kit.BasicServicesKit'

@ObservedV2
export class BaseA {
  static MESSAGE_ONE = "1"
  static MESSAGE_TWO = "2"
  @Trace number1: number = 0
  @Trace number2: number = 0

  private callback1 = () => {
    this.number1 += 1
  }

  register1() {
    emitter.on(BaseA.MESSAGE_ONE, this.callback1)
  }

  register2() {
    emitter.on(BaseA.MESSAGE_TWO, () => this.callback2())
  }

  private callback2() {
    this.number2 += 1
  }
}

@Entry
@Component
struct EmitterTestPage {
  viewModel: BaseA = new BaseA()

  build() {
    Column({ space: 10 }) {
      Text("callback1: " + this.viewModel.number1.toString())
        .fontSize(18).fontColor('#f00').margin({ top: 20 })
      Text("callback2: " + this.viewModel.number2.toString())
        .fontSize(18).fontColor('#0f0').margin({ top: 20 })
      Button('Send message, update directly').onClick(() => {
        this.viewModel.number1 += 1
        this.viewModel.number2 += 1
      })
      Button('Subscribe in ViewModel (method 1)').onClick(() => {
        this.viewModel.register1()
      })
      Button('Subscribe in ViewModel (method 2)').onClick(() => {
        this.viewModel.register2()
      })
      Button('Send message, update MessageOne').onClick(() => {
        emitter.emit(BaseA.MESSAGE_ONE)
      })
      Button('Send message, update MessageTwo').onClick(() => {
        emitter.emit(BaseA.MESSAGE_TWO)
      })
    }.margin({ top: 20 })
  }
}

Verification Result

  • Both solutions ensure that state changes in number1 and number2 trigger UI refresh.
  • Option 1 works by reassigning the callback after constructor execution.
  • Option 2 works by directly marking fields as reactive using @ObservedV2 and @Trace.

Written by Muhammet Cagri Yilmaz

Leave a Reply