Search Cities Example

In this example, we auto-complete cities we type in a text field. When the city text field changes, we perform a network request. This network request will be done using an AsyncAction. (Check the Search cities application source code on GitHub)

Our application state looks like:

struct SearchCities {
  var cities: [String]
}
class SearchCities(cities: List<String>) {

    private val cities: List<String>

    init {
        this.cities = ArrayList(cities)
    }

    fun getCities(): List<String> {
        return ArrayList(cities)
    }
}
class SearchCities {

    private final List<String> cities;

    SearchCities(List<String> cities) {
        this.cities = new ArrayList<>(cities);
    }

    List<String> getCities() {
        return new ArrayList<>(cities);
    }
}

We need to define an AsyncAction that fetches the cities from the network. In this async action we perform a URLSession data task request to https://autocomplete.wunderground.com/ with our query string.

When the request completes, we parse the received data, prepare a list of cities and dispatch an action that contains this list.

Let's see how that looks in code:

struct FetchCityAsyncAction: AsyncAction {
  let query: String

  init(query: String) {
    self.query = query
  }

  func execute(getState: @escaping GetStateFunction, dispatch: @escaping DispatchFunction) {
    let url = URL(string: "https://autocomplete.wunderground.com/aq?query=\(query)")!

// Perform the URL Requeus with URLSession
URLSession(configuration: .default).dataTask(with: url) { data, response, error in
    // Get the response
    let resp = try! JSONSerialization.jsonObject(with: data!, options: []) as! [String: Any]
    let result = resp["RESULTS"] as! [[String: Any]]

    // Prepare a list of cities
    let cities = result.map({ $0["name"] as! String })
    // Now we have the cities
    // We dispatch the cities
    dispatch(CitiesFetchedAction(cities: cities))
}.resume()
  }
}
class FetchCityAsyncAction(private val query: String) : AsyncAction {

    override fun execute(dispatcher: Dispatcher, getState: GetState) {

        SearchCitiesService.searchCitiesAsync(query) { cities ->
            val citiesFetchedAction = CitiesFetchedAction(cities)
            dispatcher.dispatch(citiesFetchedAction)
        }
    }
}
class FetchCityAsyncAction implements AsyncAction {

    private final String query;

    FetchCityAsyncAction(String query) {
        this.query = query;
    }

    @Override
    public void execute(Dispatcher dispatcher, GetState getState) {
        SearchCitiesService.searchCitiesAsync(query, cities -> {
            Action citiesFetchedAction = new CitiesFetchedAction(cities);
            dispatcher.dispatch(citiesFetchedAction);
        });
    }
}

The CitiesFetchedAction is an action that contains the list of cities:

struct CitiesFetchedAction: Action {
  let cities: [String]
}
class CitiesFetchedAction(cities: List<String>?) : Action<List<String>>(ACTION_TYPE, cities) {
    companion object {
        private val ACTION_TYPE = "CitiesFetchedAction"
    }
}
class CitiesFetchedAction extends Action<List<String>> {

    private static final String ACTION_TYPE = "CitiesFetchedAction";

    CitiesFetchedAction(@Nullable List<String> cities) {
        super(ACTION_TYPE, cities);
    }
}

The reducer for this example only reduces CitiesFetchedAction. In this simple example, it takes the cities from the CitiesFetchedAction and fills the SearchCities State with it.

struct SearchCitiesReducer: Reducer {
  var initialState = SearchCities(cities: [])

  func reduce(state: SearchCities, action: Action) -> SearchCities? {

    // Handle each action
    if let action = action as? CitiesFetchedAction {
      return SearchCities(cities: action.cities)
    }

    // Important: If action does not affec the state, return nil
    return nil
  }
}
class SearchCitiesReducer : Reducer<SearchCities>() {

    override fun reduce(
            state: SearchCities,
            action: Action<*>): SearchCities? {

        return if (action is CitiesFetchedAction) {
            SearchCities(action.getData<List<String>>()!!)
        } else null

    }

    override fun getInitialState(): SearchCities {
        return SearchCities(ArrayList())
    }
}
class SearchCitiesReducer extends Reducer<SearchCities> {

    @Nullable
    @Override
    public SearchCities reduce(@NonNull SearchCities state, 
                               @NonNull Action<?> action) {

        if (action instanceof CitiesFetchedAction) {
            return new SearchCities(action.getData());
        }

        return null;
    }

    @NonNull
    @Override
    public SearchCities getInitialState() {
        return new SearchCities(new ArrayList<>());
    }
}

Now that we have the Reducer, the Action and the State its time to create a store with them.

let store = Suas.createStore(reducer: SearchCitiesReducer(), middleware: AsyncMiddleware())
val store = Suas.createStore(SearchCitiesReducer())
    .withMiddleware(AsyncMiddleware())
    .build()
Store store = Suas.createStore(new SearchCitiesReducer())
    .withMiddleware(new AsyncMiddleware())
    .build();

Notice that we also must use pass the AsyncMiddleware to handle our FetchCityAsyncAction async action.

Finally, let's take a look at our UI: SearchCitiesViewController.

class SearchCitiesViewController: UIViewController {

  @IBOutlet weak var resultTextView: UITextView!
  var listenerSubscription: Subscription<SearchCities>?

  override func viewDidLoad() {
    // Add a listener
    listenerSubscription = store.addListener(forStateType: SearchCities.self) { [weak self] state in
      // Notice that we capture self weakly to prevent strong memory cycles
      
      // Combine the cities and set them in the result text view
      self.resultTextView.text = state.cities.joined(separator: "\n")
    }
  }
  
  @IBAction func textChanged(_ sender: Any) {
    let textField = sender as! UITextField
    
    // When text change dispatch the async function
    store.dispatch(action: FetchCityAsyncAction(query: textField.text ?? ""))
  }

  deinit {
    // Finally, dont forget to remove the listener
    listenerSubscription?.removeListener()
  }
}
class SearchCitiesActivity : Activity(), Listener<SearchCities> {

    private var store: Store? = null
    private var citiesAdapter: CitiesAdapter? = null
    private var cityInput: EditText? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        //...
        cityInput = findViewById(R.id.city_field)
        cityInput!!.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {}

            override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {
                val query = charSequence.toString()
                val searchCitiesAction = AsyncMiddleware.create(FetchCityAsyncAction(query))
                store!!.dispatch(searchCitiesAction)
            }

            override fun afterTextChanged(editable: Editable) {}
        })

        //...
        store = Suas.createStore(SearchCitiesReducer())
                .withMiddleware(AsyncMiddleware())
                .build()

        //...
        citiesAdapter = CitiesAdapter()
    }

    override fun onStart() {
        super.onStart()
        store!!.addListener(SearchCities::class.java, this)
    }

    override fun onStop() {
        super.onStop()
        store!!.removeListener(this)
    }

    override fun update(state: SearchCities) {
        citiesAdapter!!.update(state)
    }
}
public class SearchCitiesActivity extends Activity implements Listener<SearchCities> {

    private Store store;
    private CitiesAdapter citiesAdapter;
    private EditText cityInput;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        //...
        cityInput = findViewById(R.id.city_field);
        cityInput.addTextChangedListener(new TextWatcher() {
 						//...
            @Override
            public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
                String query = charSequence.toString();
                Action searchCitiesAction = AsyncMiddleware.create(new FetchCityAsyncAction(query));
                store.dispatch(searchCitiesAction);
            }
        });

        //...
        store = Suas.createStore(new SearchCitiesReducer())
                .withMiddleware(new AsyncMiddleware())
                .build();

        //...
        citiesAdapter = new CitiesAdapter();
    }

    @Override
    protected void onStart() {
        super.onStart();
        store.addListener(SearchCities.class, this);
    }

    @Override
    protected void onStop() {
        super.onStop();
        store.removeListener(this);
    }

    @Override
    public void update(@NonNull SearchCities state) {
        citiesAdapter.update(state);
    }
}

When the text changes in our search cities text field, we dispatch our FetchCityAsyncAction async action. This action, when executed, will fetch the cities from the network and eventually. When the network request is completed it triggers a CitiesFetchedAction which alters with the fetched cities.

Notice that we hold a reference to the subscription with listenerSubscription. We will need it in the future to remove the listener when the view controller is deallocated.

When SearchCitiesViewController is deallocated (deinit is called) we removed the listener by calling listenerSubscription?.removeListener().

What's Next

Check URLSessionAsyncAction GitHub gist example. Also check the full Search cities application source code on GitHub.

Related Topics

Check these three examples on how to implement AsyncAction:

Other sample apps

List of sample applications
Counter App Example
Todo App Example
Todo app with settings example