Examples

Best Practices

Learn best practices and patterns for using Shard effectively.

State Design

Use Immutable State

Always create new state objects instead of mutating existing ones:

// ❌ Bad
class BadShard extends Shard<List<String>> {
  BadShard() : super([]);
  
  void addItem(String item) {
    state.add(item); // Mutating state
    notifyListeners();
  }
}

// ✅ Good
class GoodShard extends Shard<List<String>> {
  GoodShard() : super([]);
  
  void addItem(String item) {
    emit([...state, item]); // New list
  }
}

Create Dedicated State Classes

For complex state, create dedicated state classes:

// ✅ Good
class AppState {
  final String? user;
  final List<String> items;
  final bool isLoading;
  final String? error;
  
  const AppState({
    this.user,
    this.items = const [],
    this.isLoading = false,
    this.error,
  });
  
  AppState copyWith({
    String? user,
    List<String>? items,
    bool? isLoading,
    String? error,
  }) {
    return AppState(
      user: user ?? this.user,
      items: items ?? this.items,
      isLoading: isLoading ?? this.isLoading,
      error: error ?? this.error,
    );
  }
}

class AppShard extends Shard<AppState> {
  AppShard() : super(const AppState());
}

Keep State Minimal

Only include what's necessary in state:

// ❌ Bad - includes derived data
class TodoState {
  final List<Todo> todos;
  final int activeCount; // Can be computed
  final int completedCount; // Can be computed
}

// ✅ Good - compute derived data
class TodoState {
  final List<Todo> todos;
}

class TodoShard extends Shard<TodoState> {
  int get activeCount => state.todos.where((t) => !t.completed).length;
  int get completedCount => state.todos.where((t) => t.completed).length;
}

Shard Organization

Single Responsibility

Each shard should have a single responsibility:

// ❌ Bad - too many responsibilities
class AppShard extends Shard<AppState> {
  // Handles todos, users, settings, etc.
}

// ✅ Good - focused responsibilities
class TodoShard extends Shard<TodoState> {
  // Only handles todos
}

class UserShard extends Shard<UserState> {
  // Only handles users
}

Keep Business Logic in Shards

Keep business logic in shards, not in widgets:

// ❌ Bad
class TodoScreen extends StatelessWidget {
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        final shard = context.read<TodoShard>();
        if (title.trim().isEmpty) return; // Logic in widget
        shard.addTodo(title);
      },
      child: Text('Add'),
    );
  }
}

// ✅ Good
class TodoShard extends Shard<TodoState> {
  void addTodo(String title) {
    if (title.trim().isEmpty) return; // Logic in shard
    emit(state.copyWith(
      todos: [...state.todos, Todo(id: '1', title: title)],
    ));
  }
}

Performance

Use ShardSelector for Partial Updates

// ❌ Bad - rebuilds on any state change
ShardBuilder<TodoShard, TodoState>(
  builder: (context, state) {
    return Text('Count: ${state.todos.length}');
  },
)

// ✅ Good - only rebuilds when count changes
ShardSelector<TodoShard, TodoState, int>(
  selector: (state) => state.todos.length,
  builder: (context, count) {
    return Text('Count: $count');
  },
)

Use buildWhen for Conditional Rebuilding

ShardBuilder<TodoShard, TodoState>(
  buildWhen: (prev, curr) => prev.todos.length != curr.todos.length,
  builder: (context, state) {
    return Text('Todo count changed');
  },
)

Avoid Unnecessary Rebuilds

// ❌ Bad
ShardBuilder<TodoShard, TodoState>(
  builder: (context, state) {
    return Text(state.searchQuery); // Only needs searchQuery
  },
)

// ✅ Good
ShardSelector<TodoShard, TodoState, String>(
  selector: (state) => state.searchQuery,
  builder: (context, query) {
    return Text(query);
  },
)

Persistence

Don't Persist Everything

Only persist what's necessary:

class TodoState {
  final List<Todo> todos;
  final String searchQuery; // Don't persist this
  
  Map<String, dynamic> toJson() {
    return {
      'todos': todos.map((t) => t.toJson()).toList(),
      // searchQuery is not persisted
    };
  }
}

Handle Migration

When state structure changes, handle migration:

@override
TodoState deserialize(String data) {
  final json = jsonDecode(data) as Map<String, dynamic>;
  
  // Handle old format
  if (!json.containsKey('todos')) {
    return TodoState(todos: []);
  }
  
  // Handle version migration
  if (json['version'] == 1) {
    return _migrateFromV1(json);
  }
  
  return TodoState.fromJson(json);
}

Use Appropriate Debounce

Adjust debounce duration based on update frequency:

// Frequent updates (search)
debounceDuration: const Duration(milliseconds: 500)

// Less frequent updates (form data)
debounceDuration: const Duration(milliseconds: 1000)

Error Handling

Handle Errors Gracefully

class TodoShard extends PersistentShard<TodoState> {
  @override
  void onLoadError(Object error, StackTrace? stackTrace) {
    // Log error
    log('Error loading todos: $error');
    
    // Optionally show user notification
    // Or retry logic
  }
  
  @override
  void onSaveError(Object error, StackTrace? stackTrace) {
    // Log error
    log('Error saving todos: $error');
    
    // Optionally retry
    Future.delayed(Duration(seconds: 1), () {
      saveState();
    });
  }
}

Testing

Test Shards in Isolation

void main() {
  test('CounterShard increments correctly', () {
    final shard = CounterShard();
    expect(shard.state, 0);
    
    shard.increment();
    expect(shard.state, 1);
    
    shard.increment();
    expect(shard.state, 2);
  });
}

Don't forget to dispose shards in tests:

test('CounterShard increments correctly', () {
  final shard = CounterShard();
  expect(shard.state, 0);
  
  shard.increment();
  expect(shard.state, 1);
  
  shard.dispose(); // Clean up
});

Test State Changes

test('TodoShard adds todo correctly', () {
  final shard = TodoShard();
  expect(shard.state.todos.length, 0);
  
  shard.addTodo('Test todo');
  expect(shard.state.todos.length, 1);
  expect(shard.state.todos[0].title, 'Test todo');
});

Code Organization

Organize by Feature

lib/
  features/
    todos/
      models/
        todo.dart
        todo_state.dart
      shard/
        todo_shard.dart
      widgets/
        todo_list_screen.dart
        todo_item.dart

Use Barrel Exports

// todos/todos.dart
export 'models/models.dart';
export 'shard/todo_shard.dart';
export 'widgets/widgets.dart';

Common Patterns

Loading States

class DataState {
  final List<Item> items;
  final bool isLoading;
  final String? error;
  
  DataState({
    this.items = const [],
    this.isLoading = false,
    this.error,
  });
}

class DataShard extends Shard<DataState> {
  Future<void> loadData() async {
    emit(state.copyWith(isLoading: true, error: null));
    
    try {
      final items = await fetchItems();
      emit(state.copyWith(items: items, isLoading: false));
    } catch (e) {
      emit(state.copyWith(isLoading: false, error: e.toString()));
    }
  }
}

Optimistic Updates

void toggleTodo(String id) {
  // Update UI immediately
  emit(state.copyWith(
    todos: state.todos.map((todo) {
      if (todo.id == id) {
        return todo.copyWith(completed: !todo.completed);
      }
      return todo;
    }).toList(),
  ));
  
  // Sync with server in background
  syncWithServer(id).catchError((error) {
    // Revert on error
    toggleTodo(id); // Toggle back
  });
}

Summary

  • Use immutable state with copyWith methods
  • Create dedicated state classes for complex state
  • Keep business logic in shards
  • Use ShardSelector for performance
  • Only persist necessary data
  • Handle errors gracefully
  • Test shards in isolation
  • Organize code by feature

Following these practices will help you build maintainable, performant Flutter apps with Shard.