Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ReactNative] ListView.DataSource doesn't work with ObservableArrays #476

Closed
winterbe opened this issue Aug 11, 2016 · 34 comments
Closed

[ReactNative] ListView.DataSource doesn't work with ObservableArrays #476

winterbe opened this issue Aug 11, 2016 · 34 comments

Comments

@winterbe
Copy link

Hi,

I'm currently evaluating MobX to be used with React Native. It seems that ListView.DataSource unfortunately doesn't work natively with observable arrays from MobX. I have to create a native array via toJS() in order the get the ListView show any items.

@observer
export default class SampleList extends Component {
    render() {
        const {store} = this.props;

        const dataSource = new ListView.DataSource({
            rowHasChanged: (r1, r2) => r1.id !== r2.id
        });

        const items = toJS(store.items);    // <= conversion to native array necessary

        return (
            <ListView
                dataSource={dataSource.cloneWithRows(items)}
                renderRow={data => (...)}
            />
        );
    }
}

I've just started experimenting with MobX but I'm a little concerned that calling toJS() for large collections on every render could lead to performance problems.

Please correct me if I'm wrong and there's another way of getting the DataSource to accept ObservableArrays.

I understand that observable types are a consequence of MobX and that you cannot ensure that every library works out of the box with those types. However in ReactNative ListView is such a fundamental component that I hope there's a decent solution when using MobX.

Thanks.

@mweststrate
Copy link
Member

If I remember correctly (not an active RN dev myself) the ListViewDataSource itself can work with observable arrays, but you need to make sure that your renderRow is an observer component. (It looks like being part of the SampleList, but actually these component callbacks have their own lifecycle. So

renderRow={RowRenderer}

//...

const RowRenderer = observer(data => {})

Should do the trick. Let me know if it doesn't :)

@winterbe
Copy link
Author

winterbe commented Aug 12, 2016

@mweststrate Thanks for your reply. Unfortunately it doesn't work for me. RowRenderer is a function that gets called with the rowData for every element in the underlying DataSource. So my code looks like this:

<ListView
  renderRow={rowData => (
    <MyRow data={rowData} .../>
  )}/>

I've modified MyRow to be an observer but it doesn't make a difference.

https://facebook.github.io/react-native/docs/listview.html

@mweststrate
Copy link
Member

Does it not react to changes in the row data, or to appending / remove items to the collection? For the latter you might need items.slice() to make sure RN recognizes as array (difference with toJS is that the first makes a cheap shallow copy, and the latter a deep copy, which shouldn't be needed as individual rowData objects are observed by MyRow).

cc: @danieldunderfelt

@danieldunderfelt
Copy link

Hi @winterbe!

I've been using mobx with RN extensively, and I haven't gotten mobx "arrays" to work with datasources either. The reason might be that mobx arrays are really objects and datasource expects an array. I always call slice on the array before feeding it to the datasource.

One trick is to create the datasource in a computed, so the computed observes the reactive array and returns a datasource when accessed. Then just have the ListView use the computed prop as its datasource.

Datasource did not work with peek either if I recall correctly. This indicates that RN is doing something with the array, more than just reading from it. This could be interesting to research, as a mobx-react-native-datasource would be a great module to have.

@winterbe
Copy link
Author

@mweststrate I don't know if it reacts to changes because the ListView doesn't render any rows at all when calling dataSource.cloneWithRows(items) with an ObservableArray instead of a native array. But calling slice seems to be a decent workaround, thanks for that!

@danieldunderfelt Using computed for the datasources is a great advice, thanks for that! I'm glad to hear people are already using MobX with ReactNative. I'm still evaluating if MobX could be a decent replacement for Redux in my app. ListView gave me a little trouble because of all the render* props which sometimes don't react to state changes. I guess I haven't understood entirely how observer actually works. 😕

@AzizAK
Copy link

AzizAK commented Aug 21, 2016

Hello, @winterbe and @danieldunderfelt could anyone show me example how to use mobx with react native.

@winterbe
Copy link
Author

Nothing special about Mobx with ReactNative. Just make sure to import
observer/native from mobx-react. And you have to call slice on observable
arrays before passing to List view data source.

Here's a starting guide:

https://medium.com/@dabit3/react-native-with-mobx-getting-started-ba7e18d8ff44#.uge82y49s

Am Sonntag, 21. August 2016 schrieb Abdulaziz Alkharashi :

Hello, @winterbe https://github.com/winterbe and @danieldunderfelt
https://github.com/danieldunderfelt could anyone show me example how to
use mobx with react native.


You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
#476 (comment), or mute
the thread
https://github.com/notifications/unsubscribe-auth/AAdmqR3ADn1jtw8Lf9xuykH33PXbRcgTks5qiEi7gaJpZM4JiRNL
.

@oriharel
Copy link

oriharel commented Sep 1, 2016

@winterbe So which way did you go? computed or slice? and why are you looking for a replacement for Redux? I'm also evaluating the same.

@winterbe
Copy link
Author

winterbe commented Sep 3, 2016

I use compute if the array presented in ListView has to be filtered first.
Otherwise I just call slice on the observable array before constructing the
ListView dataSource.

Am Donnerstag, 1. September 2016 schrieb Ori Harel :

So which way did you go? computed or slice?


You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
#476 (comment), or mute
the thread
https://github.com/notifications/unsubscribe-auth/AAdmqScY7cbteFrbbqSpg_A8VQM21eRqks5qlzNOgaJpZM4JiRNL
.

@ajma
Copy link

ajma commented Sep 23, 2016

I ran into a problem when I had a list with section headers. After digging through some code, it turns out that ListViewDataSource when it was calculating rowIdentities on an ObservableArray, it does Objects.keys on it. RN expects that it would output the indexes of the array, but it doesn't because it's an Observable Array. My solution here is when I call cloneWithRowsAndSections, I have to pass in the sectionIdentities and rowIdenties myself

  dataSource: ds.cloneWithRowsAndSections(
    list,
    Object.keys(list),
    Object.keys(list).map((sectionID) => Object.keys(list[sectionID].slice()))),

@ghost
Copy link

ghost commented Sep 30, 2016

@danieldunderfelt can you share some code? the following doesn't work
ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1 != r2 }); @computed get list() { return this.ds.cloneWithRows(this._list); }
<ListView dataSource={listStore.list} />

@ajma
Copy link

ajma commented Oct 1, 2016

@sonayen did you try wrapping it in mobx.toJS?

@ghost
Copy link

ghost commented Oct 1, 2016

@ajma tried dataSource={toJS(listStore.list)} and cloneWithRows(toJS(this._list));, both didn't work

@ghost
Copy link

ghost commented Oct 1, 2016

Here is my full attempt:

import React, { Component } from 'react';
import { ListView, Text } from 'react-native';

import { computed, observable } from 'mobx';
import { observer } from 'mobx-react/native';

class ListStore {
  @observable list = [
    { text: 'Hello World!' },
    { text: 'Hello React Native!' },
    { text: 'Hello MobX!' }
  ];

  ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1 !== r2 });
  @computed get dataSource() { return this.ds.cloneWithRows(this.list); }
}

const listStore = new ListStore();

@observer class List extends Component {
  /*
  list = [
    { text: 'Hello World!' },
    { text: 'Hello React Native!' },
    { text: 'Hello MobX!' }
  ];

  ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1 !== r2 });

  state = { dataSource: this.ds.cloneWithRows(this.list) };
  */

  render() {
    return (
      <ListView
        dataSource={listStore.dataSource}
        renderRow={row => <Text>{row.text}</Text>}
        enableEmptySections={true}
      />
    );
  }
}

export default List;

@ghost
Copy link

ghost commented Oct 2, 2016

@ajma any thoughts?

@danieldunderfelt
Copy link

@sonayen Hi! You need to slice() the observable array before giving it to cloneWithRows. Otherwise your code above looks good!

@ghost
Copy link

ghost commented Oct 2, 2016

@danieldunderfelt finally it worked! thanks.
i am sure someone will encounter the same problem in the future since this is a common use case, so i will post the full working code.

import React, { Component } from 'react';
import { ListView, Text, TouchableOpacity, View } from 'react-native';

import { action, computed, observable } from 'mobx';
import { observer } from 'mobx-react/native';

class ListStore {
  @observable list = [
    'Hello World!',
    'Hello React Native!',
    'Hello MobX!'
  ];

  ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1 !== r2 });

  @computed get dataSource() {
    return this.ds.cloneWithRows(this.list.slice());
  }

  @action add = title => this.list.push(title);
}

const listStore = new ListStore();

@observer class List extends Component {
  render() {
    return (
      <View style={{ flex: 1, padding: 10 }}>
        <ListView
          dataSource={listStore.dataSource}
          renderRow={row => <Text>{row}</Text>}
          enableEmptySections={true}
        />

        <TouchableOpacity onPress={() => listStore.add('Lorem ipsum dolor sit amet')} style={{ bottom: 0 }}>
          <Text>listStore.add()</Text>
        </TouchableOpacity>
      </View>
    );
  }
}

export default List;

img_2152

@mweststrate
Copy link
Member

@sonayen would you mind documenting the problem and solution approach here? https://github.com/mobxjs/mobx/blob/gh-pages/docs/best/pitfalls.md I think that would be really useful for people running into this in the future!

@ghost
Copy link

ghost commented Oct 3, 2016

@mweststrate sure thing, i will work on it

@feroult
Copy link

feroult commented Oct 6, 2016

It seems that the solution above doesn't work for inner object property updates. For instance, if we have this observable, instead of a plain string array:

@observable list = [
    { text: 'Hello World!' },
    { text: 'Hello React Native!' },
    { text: 'Hello MobX!' }
  ];

Then our renderRow method should be renderRow={row => <Text>{row.text}</Text>}.

Now if an @action update an item, like list[0].text = 'new text', the ListView won't update.

The renderRow function won't fire the reactions to recompute the dataSource() method. I think this is right, because inside the dataSource() we don't touch the inner object properties.

The following hack will fire the ListView update after the item changes, but it doesn't feel right :)

 @computed get dataSource() {
    this.list.forEach(e => e.text); 
    return this.ds.cloneWithRows(this.list.slice());
  }

Finally, this issue tells that we need to clone and update the object in the array instead of just changing it's properties. Which doesn't feel right too.

Since the hack works (although it re-renders all items in the ListView), it seems that it is possible to handle it properly with mobx, right?

Does anyone have a better idea on how to observe and fire the updates by using the inner property access that happens inside the renderRow method call?

@ghost
Copy link

ghost commented Oct 6, 2016

@feroult it seems that iteration is the solution for now (es5 compatibility related).

one down side also would be that you have to explicitly iterate through each object key that you wish to have its value observed:

@observable list = [{ text: 'foo', subtext: 'bar' }];
@computed get dataSource() { this.list.forEach(e => [ e.text, e.subtext ]); // ..

more discussion about this here.

@danieldunderfelt
Copy link

@feroult The reason you're not seeing updates is that renderRow does not react to changes. observer only makes the render function re-run on changes, not any other function. You need to make sure that your row component state is used within a render function, and the easiest way to do that is to create a separate component.

Then your renderRow is simply:

renderRow(row) {
  return <RowComponent data={ row } />
}

So it is not the datasource that is your problem at all, it is your render function.

@mweststrate
Copy link
Member

@capaj
Copy link
Member

capaj commented Oct 26, 2016

I just wonder why is this closed? Is it wont fix? Is it fixed? Could it be that ListView.DataSource iterates the array using a for loop? If that is the case, I think we can fix it very easily just by making properties of observableArray enumerable. Am I missing something or is everyone missing the most obvious solution? @winterbe @mweststrate

@MovingGifts
Copy link

MovingGifts commented Nov 29, 2016

@danieldunderfelt Extracting the component and having a change in the mobx observable array does not seem to refresh the ListView for me...

Here is what I got:

const dataSource = new ListView.DataSource({ rowHasChanged: ( r1, r2 ) => r1.id !== r2.id });

@observer
export default class Movies extends Component {
    @computed get dataSource() {
        return dataSource.cloneWithRows(MovieStore.moviesList.slice());
    }

    render() {
        return (
            <View>
                <ListView
                  renderRow={(row) => <Row data={row} />}
                  dataSource={this.dataSource}
                  />
            </View>
       )
    }
}

Extracted row component:

@observer
export default class Row extends Component {
    render() {
      const movie = this.props.data;

      return (
        <View key={movie.id}>
            <View>
              <MovieItem movie={movie} />
            </View>
        </View>
      )
    }
}

A movie in the initial MovieStore.moviesList has many attributes that may change and the ListView never updates to reflect that change. For example, if a movie in the loaded list has a rating of 0, and it was changed to 5 in the MovieStore.moviesList, the ListView still displays 0, instead of refreshing to reflect 5.

I believe I implemented what you meant above for the ListView to reload on change of an attribute. Since the movie's attribute in MovieStore.moviesList changed, and that is the dataSource, with an extracted row component, why is it not reloading?

@danieldunderfelt
Copy link

danieldunderfelt commented Nov 29, 2016

@MovingGifts You need to also decorate your Row component with @observer. As a rule of thumb, decorate all your components with @observer by default!

Also, you might want to do some changes to your DataSource. Right now, I don't believe that rowHasChanged is ever called, and if it would be called it would always return true as you're comparing objects. To have it be called when you clone, you need to always clone the previous clone of the DataSource! The simplest way to do this is to put dataSource as a property on the component class and always reassign it when you clone a new DataSource, that you of course clone from the dataSource property.

To have the function actually DO anything, I recommend comparing the id properties of the objects: rowHasChanged: ( r1, r2 ) => r1.id !== r2.id, of course assuming that they always have that id property.

I hope that makes sense! The DataSource is a bit finicky.

@MovingGifts
Copy link

@danieldunderfelt Thank you so much for getting back so soon. I updated the code above to have an @observer on the row component, and ( r1, r2) => r1.id !== r2.id but still the changes don't reflect.

I think the only thing missing is what you said here:

To have it be called when you clone, you need to always clone the previous clone of the DataSource! The simplest way to do this is to put dataSource as a property on the component class and always reassign it when you clone a new DataSource, that you of course clone from the dataSource property.

Do you mind sharing what that code looks like based on my code above, as I am not sure 100% what's left to implement to get it to work?

@MovingGifts
Copy link

@danieldunderfelt Do you mind sharing what that code looks like based on my code above, as I am not sure 100% what's left to implement to get it to work?

@feroult
Copy link

feroult commented Dec 1, 2016

@MovingGifts

Can you confirm that the movie objects inside the MovieStore.moviesList are also observables?

If this is the case, they should be triggering the render of the Row component when their properties are changed.

@MovingGifts
Copy link

@feroult Yup, they are observable @observable moviesList = []; and then they get populated after a server call for the movies list. I am not sure if this is the missing thing from my code above that @danieldunderfelt suggested:

To have it be called when you clone, you need to always clone the previous clone of the DataSource! The simplest way to do this is to put dataSource as a property on the component class and always reassign it when you clone a new DataSource, that you of course clone from the dataSource property.

@binchik
Copy link

binchik commented Dec 4, 2016

@MovingGifts I had the same problem. I ended up importing Observer component from mobx-react. Then modify your renderRow code to look like this:

renderRow = () => ( <Observer> {() => <MyRow />} </Observer> );

Now the rows are rerendered as observable data changes.

@MovingGifts
Copy link

@danieldunderfelt @feroult @binchik Thank you so much for all your help guys.

The issue was the nested component MovieItem not having the @observer, once added it worked!

@hwnprsd
Copy link

hwnprsd commented Jun 25, 2017

Hey guys!
I was using FlatList from React-Native as it is more efficient in handling a large number of rows.
I also was going through the process of understanding MobX, so I decided to create this Todo list app.
My code is as shown
---Store----

/**
 * Created by d3fkon on 25/6/17.
 */
import { observable, action, computed } from 'mobx';

class DataStore {
    @observable todoList = [{value: "Welcome", checked: false, id: 0}, {value: "MLG", checked: false, id: 1}];
    i = 2;
    @action createTodo = (todo) => this.todoList.push({value: todo, checked: false, id: this.i++});

    get getList() {
        console.log(this.todoList.slice());
        return this.todoList.slice();
    }
}
export default new DataStore();

---Todo Component----

/**
 * Created by d3fkon on 25/6/17.
 */
import React, { Component } from 'react';
import {
    View,
    FlatList
} from 'react-native';
import {
    ListItem,
    CheckBox,
    H2,
    Input,
    Item,
    Label,
    Button,
    Text
} from 'native-base';
import { observer } from 'mobx-react/native';
@observer
export default class Home extends Component {

    _renderListItem = (item) => {
        console.log(item);
        return (
            <ListItem>
                <Text>{item.value}</Text>
            </ListItem>
        )
    };
    render() {
        let { Store } = this.props.screenProps;
        return(
            <FlatList
                ListHeaderComponent={<ListHeader Store={Store}/>}
                data={Store.getList}
                renderItem={({item}) => this._renderListItem(item)}
                keyExtractor={(item, id) => item.id}
            />
        )
    }
}
@observer
class ListHeader extends Component {
    constructor() {
        super();
        this.state = {
            todoInput: ""
        }
    }
    addTodo() {
        let { Store } = this.props;
        console.log(Store.getList);
        Store.createTodo(this.state.todoInput);
    }
    render() {
        return(
            <View style={{flex: 8, padding: 10, flexDirection: 'row'}}>
                <Item floatingLabel style={{flex: 7}}>
                    <Label>New Todo</Label>
                    <Input onChange={(todo) => this.setState({todoInput: todo})}/>
                </Item>
                <Button style={{flex: 1, justifyContent: 'center'}} onPress={this.addTodo.bind(this)}><Text>Add</Text></Button>
            </View>
        )
    }
}

I get this error each time I add a new Todo.

Objects are not valid as a React child (found: object with keys {dispatchConfig, _targetInst, isDefaultPrevented, isPropagationStopped, _dispatchListeners, _dispatchInstances, nativeEvent, type, target, currentTarget, eventPhase, bubbles, cancelable, timeStamp, defaultPrevented, isTrusted}). If you meant to render a collection of children, use an array instead.
I believe that it is because my data prop inside my FlatList component is not receiving an array., even after using this.todoList.slice() which seems odd to me.
Any help is appreciated. Thanks!

@arshadd
Copy link

arshadd commented Sep 29, 2018

Hi,
I am sorry but I have been struggling with List view rendering

class ProductStore {
@observable featureproducts = [];
ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1 !== r2 });

  @computed get dataSource() {
    return this.ds.cloneWithRows(this.featureproducts.slice());
  }

@action fetchFeatureProducts() {
    var url = Config.API_BASE_URL + 'api/v1/product/featureproducts';
    return API.getFeatureProdcutService().then((resp) => {
      if (resp.status) {
        this.setFeatureProduct(resp);
      }
    }).catch((err) => {
      console.log('error',err);
    });
  }


  @action setFeatureProduct(productData) {
    this.featureproducts = productData.data.data;
  }
}

Now for the component

`@inject( "productStore", "app", "routerActions")
@observer
class HomePage extends Component {
componentDidMount() {
this.props.productStore.fetchFeatureProducts()

}
render() {

<List
removeClippedSubviews={false}
bounces={false}
directionalLockEnabled={false}
horizontal={true}
showsHorizontalScrollIndicator={false}
dataArray={this.props.productStore.dataSource}
renderRow={item =>
<BannerSlider
imageStyle={styles.bannerSliderImage}
onPress={() => navigation.navigate("ProductList")}
bannerImageText={item.image}
bannerImageSource={item.image}
bannerSmallText={'item.bannerSmallText'}
/>}
/>}
}`

I am using nativebase list which is using listview internally not sure why all the stuff doesn work

Also I have kept
`constructor(props) {
super(props);

const ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1 !== r2 });
this.state = {
    DataSource: ds.cloneWithRows(this.props.productStore.featureproducts.slice())
}`

not sure where should I call this.setsate to update changes plz help

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests