Saltar al contenido principal

State optimista

Mejora la percepción de capacidad de respuesta de una aplicación implementando un State optimista.

Al construir experiencias de usuario, la percepción del rendimiento a veces es tan importante como el rendimiento real del código. En general, a los usuarios no les gusta esperar a que termine una acción para ver el resultado, y cualquier cosa que tarde más de unos pocos milisegundos podría considerarse "lenta" o "sin respuesta" desde la perspectiva del usuario.

Los desarrolladores pueden ayudar a mitigar esta percepción negativa al presentar un UI State exitoso antes de que la tarea en segundo plano se complete por completo. Un ejemplo de esto sería tocar un botón de "Suscribirse" y ver que cambia a "Suscrito" instantáneamente, incluso si la llamada en segundo plano a la API de suscripción aún se está ejecutando.

Esta técnica se conoce como Optimistic State, Optimistic UI o Experiencia de Usuario Optimista. En esta receta, implementarás una funcionalidad de aplicación utilizando Optimistic State y siguiendo las pautas de arquitectura de Flutter.

Funcionalidad de ejemplo: un botón de suscripción

#

Este ejemplo implementa un botón de suscripción similar al que podrías encontrar en una aplicación de transmisión de video o un boletín informativo.

Aplicación con botón de suscripción

Cuando se toca el botón, la aplicación llama a una API externa, realizando una acción de suscripción, por ejemplo registrando en una base de datos que el usuario ahora está en la lista de suscripción. Con fines de demostración, no implementarás el código de backend real, sino que reemplazarás esta llamada con una acción simulada que simulará una solicitud de red.

En caso de que la llamada tenga éxito, el texto del botón cambiará de "Suscribirse" a "Suscrito". El color de fondo del botón también cambiará.

Por el contrario, si la llamada falla, el texto del botón debe volver a "Suscribirse", y la UI debe mostrar un mensaje de error al usuario, por ejemplo usando un Snackbar.

Siguiendo la idea del Optimistic State, el botón debería cambiar instantáneamente a "Suscrito" una vez que se toca, y solo volver a "Suscribirse" si la solicitud falla.

Animación de la aplicación con botón de suscripción

Arquitectura de la funcionalidad

#

Comienza definiendo la arquitectura de la funcionalidad. Siguiendo las pautas de arquitectura, crea estas clases de Dart en un proyecto Flutter.

  • Un StatefulWidget llamado SubscribeButton
  • Una clase llamada SubscribeButtonViewModel que extiende de ChangeNotifier
  • Una clase llamada SubscriptionRepository
dart
class SubscribeButton extends StatefulWidget {
  const SubscribeButton({super.key});

  @override
  State<SubscribeButton> createState() => _SubscribeButtonState();
}

class _SubscribeButtonState extends State<SubscribeButton> {
  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

class SubscribeButtonViewModel extends ChangeNotifier {}

class SubscriptionRepository {}

El Widget SubscribeButton y el SubscribeButtonViewModel representan la capa de presentación de esta solución. El Widget va a mostrar un botón que mostrará el texto "Suscribirse" o "Suscrito" dependiendo del State de suscripción. El view model contendrá el State de suscripción. Cuando se toca el botón, el Widget llamará al view model para realizar la acción.

El SubscriptionRepository implementará un método subscribe que lanzará una excepción cuando la acción falle. El view model llamará a este método al realizar la acción de suscripción.

A continuación, conéctalos agregando el SubscriptionRepository al SubscribeButtonViewModel:

dart
class SubscribeButtonViewModel extends ChangeNotifier {
  SubscribeButtonViewModel({required this.subscriptionRepository});

  final SubscriptionRepository subscriptionRepository;
}

Y agrega el SubscribeButtonViewModel al Widget SubscribeButton:

dart
class SubscribeButton extends StatefulWidget {
  const SubscribeButton({super.key, required this.viewModel});

  /// Subscribe button view model.
  final SubscribeButtonViewModel viewModel;

  @override
  State<SubscribeButton> createState() => _SubscribeButtonState();
}

Ahora que has creado la arquitectura básica de la solución, puedes crear el Widget SubscribeButton de la siguiente manera:

dart
SubscribeButton(
  viewModel: SubscribeButtonViewModel(
    subscriptionRepository: SubscriptionRepository(),
  ),
)

Implementar el SubscriptionRepository

#

Agrega un nuevo método asíncrono llamado subscribe() al SubscriptionRepository con el siguiente código:

dart
class SubscriptionRepository {
  /// Simulates a network request and then fails.
  Future<void> subscribe() async {
    // Simulate a network request
    await Future.delayed(const Duration(seconds: 1));
    // Fail after one second
    throw Exception('Failed to subscribe');
  }
}

La llamada a await Future.delayed() con una duración de un segundo se ha agregado para simular una solicitud de larga duración. La ejecución del método se pausará por un segundo y luego continuará ejecutándose.

Para simular el fallo de una solicitud, el método subscribe lanza una excepción al final. Esto se utilizará más adelante para mostrar cómo recuperarse de una solicitud fallida al implementar Optimistic State.

Implementar el SubscribeButtonViewModel

#

Para representar el State de suscripción, así como un posible State de error, agrega los siguientes miembros públicos al SubscribeButtonViewModel:

dart
// Whether the user is subscribed
bool subscribed = false;

// Whether the subscription action has failed
bool error = false;

Ambos se establecen en false al inicio.

Siguiendo las ideas del Optimistic State, el State subscribed cambiará a true tan pronto como el usuario toque el botón de suscripción. Y solo volverá a cambiar a false si la acción falla.

El State error cambiará a true cuando la acción falle, indicando al Widget SubscribeButton que muestre un mensaje de error al usuario. La variable debería volver a false una vez que se haya mostrado el error.

A continuación, implementa un método asíncrono subscribe():

dart
// Subscription action
Future<void> subscribe() async {
  // Ignore taps when subscribed
  if (subscribed) {
    return;
  }

  // Optimistic state.
  // It will be reverted if the subscription fails.
  subscribed = true;
  // Notify listeners to update the UI
  notifyListeners();

  try {
    await subscriptionRepository.subscribe();
  } catch (e) {
    print('Failed to subscribe: $e');
    // Revert to the previous state
    subscribed = false;
    // Set the error state
    error = true;
  } finally {
    notifyListeners();
  }
}

Como se describió anteriormente, primero el método establece el State subscribed en true y luego llama a notifyListeners(). Esto obliga a actualizar la UI y el botón cambia su apariencia, mostrando el texto "Suscrito" al usuario.

Luego, el método realiza la llamada real al repositorio. Esta llamada está envuelta por un try-catch para capturar cualquier excepción que pueda lanzar. En caso de que se capture una excepción, el State subscribed se vuelve a establecer en false, y el State error se establece en true. Se realiza una llamada final a notifyListeners() para volver a cambiar la UI a 'Subscribe'.

Si no hay excepciones, el proceso está completo porque la UI ya refleja el State de éxito.

El SubscribeButtonViewModel completo debería verse así:

dart
/// Subscribe button View Model.
/// Handles the subscribe action and exposes the state to the subscription.
class SubscribeButtonViewModel extends ChangeNotifier {
  SubscribeButtonViewModel({required this.subscriptionRepository});

  final SubscriptionRepository subscriptionRepository;

  // Whether the user is subscribed
  bool subscribed = false;

  // Whether the subscription action has failed
  bool error = false;

  // Subscription action
  Future<void> subscribe() async {
    // Ignore taps when subscribed
    if (subscribed) {
      return;
    }

    // Optimistic state.
    // It will be reverted if the subscription fails.
    subscribed = true;
    // Notify listeners to update the UI
    notifyListeners();

    try {
      await subscriptionRepository.subscribe();
    } catch (e) {
      print('Failed to subscribe: $e');
      // Revert to the previous state
      subscribed = false;
      // Set the error state
      error = true;
    } finally {
      notifyListeners();
    }
  }
}

Implementar el SubscribeButton

#

En este paso, primero implementarás el método build del SubscribeButton, y luego implementarás el manejo de errores de la funcionalidad.

Agrega el siguiente código al método build:

dart
@override
Widget build(BuildContext context) {
  return ListenableBuilder(
    listenable: widget.viewModel,
    builder: (context, _) {
      return FilledButton(
        onPressed: widget.viewModel.subscribe,
        style: widget.viewModel.subscribed
            ? SubscribeButtonStyle.subscribed
            : SubscribeButtonStyle.unsubscribed,
        child: widget.viewModel.subscribed
            ? const Text('Subscribed')
            : const Text('Subscribe'),
      );
    },
  );
}

Este método build contiene un ListenableBuilder que escucha los cambios del view model. El builder luego crea un FilledButton que mostrará el texto "Suscrito" o "Suscribirse" dependiendo del State del view model. El estilo del botón también cambiará dependiendo de este State. Asimismo, cuando se toca el botón, ejecuta el método subscribe() del view model.

El SubscribeButtonStyle se puede encontrar aquí. Agrega esta clase junto al SubscribeButton. Siéntete libre de modificar el ButtonStyle.

dart
class SubscribeButtonStyle {
  static const unsubscribed = ButtonStyle(
    backgroundColor: WidgetStatePropertyAll(Colors.red),
  );

  static const subscribed = ButtonStyle(
    backgroundColor: WidgetStatePropertyAll(Colors.green),
  );
}

Si ejecutas la aplicación ahora, verás cómo cambia el botón cuando lo presionas, sin embargo, volverá al State original sin mostrar un error.

Manejar errores

#

Para manejar errores, agrega los métodos initState() y dispose() al SubscribeButtonState, y luego agrega el método _onViewModelChange().

dart
@override
void initState() {
  super.initState();
  widget.viewModel.addListener(_onViewModelChange);
}

@override
void dispose() {
  widget.viewModel.removeListener(_onViewModelChange);
  super.dispose();
}
dart
/// Listen to ViewModel changes.
void _onViewModelChange() {
  // If the subscription action has failed
  if (widget.viewModel.error) {
    // Reset the error state
    widget.viewModel.error = false;
    // Show an error message
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(const SnackBar(content: Text('Failed to subscribe')));
  }
}

La llamada addListener() registra el método _onViewModelChange() para que sea llamado cuando el view model notifique a sus escuchas. Es importante llamar a removeListener() cuando se desecha (dispose) el Widget, para evitar errores.

El método _onViewModelChange() verifica el State error, y si es true, muestra un Snackbar al usuario mostrando un mensaje de error. Asimismo, el State error se vuelve a establecer en false, para evitar mostrar el mensaje de error varias veces si se vuelve a llamar a notifyListeners() en el view model.

Optimistic State avanzado

#

En este tutorial, has aprendido a implementar un Optimistic State con un solo State binario, pero puedes usar esta técnica para crear una solución más avanzada incorporando un tercer State temporal que indique que la acción aún se está ejecutando.

Por ejemplo, en una aplicación de chat, cuando el usuario envía un nuevo mensaje, la aplicación mostrará el nuevo mensaje de chat en la ventana de chat, pero con un icono que indica que el mensaje aún está pendiente de entrega. Cuando se entrega el mensaje, ese icono se eliminará.

En el ejemplo del botón de suscripción, podrías agregar otro indicador (flag) en el view model que indique que el método subscribe() aún se está ejecutando, o utilizar el State running del patrón Command, luego modificar ligeramente el estilo del botón para mostrar que la operación está en ejecución.

Ejemplo interactivo

#

Este ejemplo muestra el Widget SubscribeButton junto con el SubscribeButtonViewModel y el SubscriptionRepository, los cuales implementan una acción de toque para suscribirse con Optimistic State.

Al tocar el botón, el texto del botón cambia de "Suscribirse" a "Suscrito". Después de un segundo, el repositorio lanza una excepción, la cual es capturada por el view model, y el botón vuelve a mostrar "Suscribirse", al mismo tiempo que muestra un Snackbar con un mensaje de error.