DEV Community

Nguyễn Hữu Hiếu
Nguyễn Hữu Hiếu

Posted on • Updated on

React Native Flatlist: Filter & Sorting

Scenarior

I read a lot of react-native flatlist guide but no guide that point enough information for this, how to use it right way, how to implement search, sort, and so on. So I decided to create one that can help you and me to ref every time working with flat list.

This guide helps you build a flat list and how to improve it based on my experiment step by step

  • Step 1: Build a flatlist
  • Step 2: Add filter condition
  • Step 3: Add highlight
  • Step 4: Expand item and stick item (only scroll content)

Step 1: Build a flatlist

import React, {useState} from 'react';
import {FlatList, StyleSheet, Text, TouchableOpacity, View} from 'react-native';

interface Post {
  id: number;
  title: string;
  description: string;
}

const postMocks: Post[] = [
  {id: 1, title: 'Post 1', description: 'Description for Post 1'},
  {id: 2, title: 'Post 2', description: 'Description for Post 2'},
  {id: 3, title: 'Post 3', description: 'Description for Post 3'},
  {id: 4, title: 'Post 4', description: 'Description for Post 4'},
  {id: 5, title: 'Post 5', description: 'Description for Post 5'},
  {id: 6, title: 'Post 6', description: 'Description for Post 6'},
  {id: 7, title: 'Post 7', description: 'Description for Post 7'},
  {id: 8, title: 'Post 8', description: 'Description for Post 8'},
  {id: 9, title: 'Post 9', description: 'Description for Post 9'},
  {id: 10, title: 'Post 10', description: 'Description for Post 10'},
  {id: 11, title: 'Post 11', description: 'Description for Post 11'},
  {id: 12, title: 'Post 12', description: 'Description for Post 12'},
  {id: 13, title: 'Post 13', description: 'Description for Post 13'},
  {id: 14, title: 'Post 14', description: 'Description for Post 14'},
  {id: 15, title: 'Post 15', description: 'Description for Post 15'},
  {id: 16, title: 'Post 16', description: 'Description for Post 16'},
  {id: 17, title: 'Post 17', description: 'Description for Post 17'},
  {id: 18, title: 'Post 18', description: 'Description for Post 18'},
  {id: 19, title: 'Post 19', description: 'Description for Post 19'},
  {id: 20, title: 'Post 20', description: 'Description for Post 20'},
  {id: 21, title: 'Post 21', description: 'Description for Post 21'},
  {id: 22, title: 'Post 22', description: 'Description for Post 22'},
  {id: 23, title: 'Post 23', description: 'Description for Post 23'},
  {id: 24, title: 'Post 24', description: 'Description for Post 24'},
  {id: 25, title: 'Post 25', description: 'Description for Post 25'},
  {id: 26, title: 'Post 26', description: 'Description for Post 26'},
  {id: 27, title: 'Post 27', description: 'Description for Post 27'},
  {id: 28, title: 'Post 28', description: 'Description for Post 28'},
  {id: 29, title: 'Post 29', description: 'Description for Post 29'},
  {id: 30, title: 'Post 30', description: 'Description for Post 30'},
];

const PostItem = React.memo(
  ({item, index}: {item: Post; index: number}) => {
    console.log('PostItem', index);
    return (
      <View style={postItemStyles.container}>
        <Text style={postItemStyles.title}>{item.title}</Text>
        <Text style={postItemStyles.description}>{item.description}</Text>
      </View>
    );
  },
  (prevProps, nextProps) => {
    // only re-render when item is changed
    return prevProps.item.id === nextProps.item.id;
  },
);

const postItemStyles = StyleSheet.create({
  container: {
    backgroundColor: '#fff',
    padding: 10,
  },
  title: {
    fontSize: 16,
    fontWeight: 'bold',
  },
  description: {
    fontSize: 14,
    marginTop: 10,
  },
});

export const FlatListDemo = () => {
  const [postList, setPostList] = useState(postMocks);
  /**
   * create renderPostItem: => can reduce anonymous function in renderPostList
   * anonymous function will be created every time renderPostList is called => so it's better to create a function outside
   * @param param0
   * @returns
   */
  const renderPostItem = ({item, index}: {item: Post; index: number}) => {
    // alway re-render each time renderPostList re-render
    // to reduce re-render UI, we can use React.memo to create new component that only handle UI
    // check by append and remove post
    console.log('renderPostItem', index);
    return <PostItem index={index} item={item} />;
  };

  /**
   *
   * @param item
   * @returns
   */
  const keyExtractor = (item: Post) => item.id.toString();

  const appendPost = () => {
    const newPost = {
      id: postList.length + 1,
      title: `Post ${postList.length + 1}`,
      description: `Description for Post ${postList.length + 1}`,
    };
    setPostList([...postList, newPost]);
  };

  const removeLastPost = () => {
    const newPostList = [...postList];
    newPostList.pop();
    setPostList(newPostList);
  };

  const renderPostList = () => {
    return (
      <FlatList
        style={postListStyles.container}
        data={postList}
        renderItem={renderPostItem}
        keyExtractor={keyExtractor}
      />
    );
  };
  return (
    <View style={styles.container}>
      <View style={styles.header}>
        {/* appendPost */}
        <TouchableOpacity onPress={appendPost} style={styles.button}>
          <Text>Append Post</Text>
        </TouchableOpacity>

        {/* removeLastPost */}
        <TouchableOpacity onPress={removeLastPost} style={styles.button}>
          <Text>Remove Last Post</Text>
        </TouchableOpacity>
      </View>
      {renderPostList()}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    borderTopColor: '#ddd',
    borderTopWidth: 1,
  },
  header: {
    backgroundColor: '#ddd',
    justifyContent: 'space-between',
    alignItems: 'center',
    flexDirection: 'row',
    padding: 10,
  },
  headerText: {
    fontSize: 16,
    fontWeight: '500',
  },
  button: {
    backgroundColor: '#fff',
    padding: 10,
    borderRadius: 5,
  },
});

const postListStyles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f2f2f2',
  },
});
Enter fullscreen mode Exit fullscreen mode
  • (1) renderPostList: that control postList
  • (2) renderPostItem: control only logic to render post item => can add filter here, if not contain just return null => nothing show
  • (3) PostItem: control UI render for postItem => we can render PostItemOdd or PostItemEven if we want, this is very helpful if you try to think about it

Step1 Result

Step 2: Add filter condition

export const FlatListDemo = () => {
  // add this
  const [keyword, setKeyword] = useState('');
  const [order, setOrder] = useState<'ASC' | 'DESC'>('ASC');

  const toggleOrder = () => {
    const newOrder = order === 'ASC' ? 'DESC' : 'ASC';
    setOrder(newOrder);
  };

  const postListFiltered = postList.filter(post =>
    post.title.toLowerCase().includes(keyword.toLowerCase()),
  );
  const postListSorted = postListFiltered.sort((a, b) => {
    if (order === 'ASC') {
      return a.title.localeCompare(b.title);
    }
    return b.title.localeCompare(a.title);
  });

  const renderPostListHeader = () => {
    return (
      <>
        <TextInput
          value={keyword}
          onChangeText={setKeyword}
          style={postListHeaderStyles.input}
        />

        <TouchableOpacity style={styles.sortButton} onPress={toggleOrder}>
          <Text>
            Order: {order} - Total: {postListSorted.length}
          </Text>
        </TouchableOpacity>
      </>
    );
  };

  // and then 
  const renderPostList = () => {
    return (
      <FlatList
        style={postListStyles.container}
        data={postListFiltered}
        ListHeaderComponent={renderPostListHeader()} // remember that we execute that function and return only the <></>
        renderItem={renderPostItem}
        keyExtractor={keyExtractor}
      />
    );
  };

// ... 
};

const postListHeaderStyles = StyleSheet.create({
  input: {
    backgroundColor: '#fff',
    padding: 10,
    margin: 10,
    borderRadius: 5,
  },
});

const styles = StyleSheet.create({
  // ...
  sortButton: {
    backgroundColor: '#d2d2d2',
    padding: 10,
    borderRadius: 5,
    borderBottomColor: '#ddd',
    borderBottomWidth: 1,
    alignItems: 'flex-end',
    marginHorizontal: 10,
  },
});
Enter fullscreen mode Exit fullscreen mode

A1 - result

Remember to execute renderHeader function otherwise you can in trouble

Issues here https://github.com/facebook/react-native/issues/13365

<FlatList
  style={postListStyles.container}
  data={postListSorted}
  ListHeaderComponent={renderPostListHeader()}
  renderItem={renderPostItem}
  keyExtractor={keyExtractor}
/>
Enter fullscreen mode Exit fullscreen mode

Step 3: Add highlight


export const FlatListDemo = () => {
  // ...
  const [selectedIdList, setSelectedIdList] = useState<number[]>([]);

  const renderPostItem = ({item, index}: {item: Post; index: number}) => {
    // check by append and remove post
    console.log('renderPostItem', index);
    const highlight = selectedIdList.includes(item.id);
    return (
      <PostItem
        index={index}
        item={item}
        highlight={highlight}
        onPress={() => {
          setSelectedIdList(curr => {
            const id = item.id;
            const newSelectedIdList = [...curr];
            const i = newSelectedIdList.indexOf(id);
            if (i === -1) {
              newSelectedIdList.push(id);
            } else {
              newSelectedIdList.splice(i, 1);
            }
            return newSelectedIdList;
          });
        }}
      />
    );
  };
  // ..

  const renderPostListHeader = () => {
    return (
      <>
        <TextInput
          value={keyword}
          onChangeText={setKeyword}
          style={postListHeaderStyles.input}
        />

        <TouchableOpacity style={styles.sortButton} onPress={toggleOrder}>
// add total selected
          <Text>
            Order: {order}. Total {postListSorted.length}. Selected{' '}
            {selectedIdList.length}
          </Text>
        </TouchableOpacity>
      </>
    );
  };
};

// and then update PostItem
const PostItem = React.memo(
  ({
    item,
    index,
    onPress,
    highlight,
  }: {
    item: Post;
    index: number;
    onPress: (post: Post) => void;
    highlight: boolean;
  }) => {
    console.log('PostItem', index);
    return (
      <TouchableOpacity
        style={[
          postItemStyles.container,
          highlight && {backgroundColor: '#ffc701'},
        ]}
        onPress={() => {
          onPress?.(item);
        }}>
        <Text style={postItemStyles.title}>{item.title}</Text>
        <Text style={postItemStyles.description}>{item.description}</Text>
      </TouchableOpacity>
    );
  },
  (prevProps, nextProps) => {
    // only re-render when item is changed
    // add one more condition to re-render when highlight
    return (
      prevProps.item.id === nextProps.item.id &&
      prevProps.highlight === nextProps.highlight
    );
  },
);

Enter fullscreen mode Exit fullscreen mode

Image description

Step 4: Expand and Collapse Item

import React, {useState} from 'react';
import {
  FlatList,
  StyleSheet,
  Text,
  TextInput,
  TouchableOpacity,
  View,
} from 'react-native';

interface Post {
  id: number;
  title: string;
  description: string;
}

const postMocks: Post[] = [
  {id: 1, title: 'Post 1', description: 'Description for Post 1'},
  {id: 2, title: 'Post 2', description: 'Description for Post 2'},
  {id: 3, title: 'Post 3', description: 'Description for Post 3'},
  {id: 4, title: 'Post 4', description: 'Description for Post 4'},
  {id: 5, title: 'Post 5', description: 'Description for Post 5'},
  {id: 6, title: 'Post 6', description: 'Description for Post 6'},
  {id: 7, title: 'Post 7', description: 'Description for Post 7'},
  {id: 8, title: 'Post 8', description: 'Description for Post 8'},
  {id: 9, title: 'Post 9', description: 'Description for Post 9'},
  {id: 10, title: 'Post 10', description: 'Description for Post 10'},
  {id: 11, title: 'Post 11', description: 'Description for Post 11'},
  {id: 12, title: 'Post 12', description: 'Description for Post 12'},
  {id: 13, title: 'Post 13', description: 'Description for Post 13'},
  {id: 14, title: 'Post 14', description: 'Description for Post 14'},
  {id: 15, title: 'Post 15', description: 'Description for Post 15'},
  {id: 16, title: 'Post 16', description: 'Description for Post 16'},
  {id: 17, title: 'Post 17', description: 'Description for Post 17'},
  {id: 18, title: 'Post 18', description: 'Description for Post 18'},
  {id: 19, title: 'Post 19', description: 'Description for Post 19'},
  {id: 20, title: 'Post 20', description: 'Description for Post 20'},
  {id: 21, title: 'Post 21', description: 'Description for Post 21'},
  {id: 22, title: 'Post 22', description: 'Description for Post 22'},
  {id: 23, title: 'Post 23', description: 'Description for Post 23'},
  {id: 24, title: 'Post 24', description: 'Description for Post 24'},
  {id: 25, title: 'Post 25', description: 'Description for Post 25'},
  {id: 26, title: 'Post 26', description: 'Description for Post 26'},
  {id: 27, title: 'Post 27', description: 'Description for Post 27'},
  {id: 28, title: 'Post 28', description: 'Description for Post 28'},
  {id: 29, title: 'Post 29', description: 'Description for Post 29'},
  {id: 30, title: 'Post 30', description: 'Description for Post 30'},
];

const PostItem = React.memo(
  ({
    item,
    index,
    toggleSelect,
    highlight,
    toggleExpand,
    expand,
  }: {
    item: Post;
    index: number;
    toggleSelect: (post: Post) => void;
    toggleExpand: (post: Post) => void;
    highlight: boolean;
    expand: boolean;
  }) => {
    console.log('PostItem', index);
    return (
      <View style={postItemStyles.wrapper}>
        <TouchableOpacity
          style={[
            postItemStyles.container,
            highlight && {backgroundColor: '#ffc701'},
          ]}
          onPress={() => {
            toggleSelect?.(item);
          }}>
          <Text style={postItemStyles.title}>{item.title}</Text>
          {/* <Text style={postItemStyles.description}>{item.description}</Text> */}
        </TouchableOpacity>
        <TouchableOpacity
          style={postItemStyles.expandButton}
          onPress={() => {
            toggleExpand?.(item);
          }}>
          <Text>{expand ? 'Collapse' : 'Expand'}</Text>
        </TouchableOpacity>
      </View>
    );
  },
  (prevProps, nextProps) => {
    // only re-render when item is changed
    return (
      prevProps.item.id === nextProps.item.id &&
      prevProps.highlight === nextProps.highlight &&
      prevProps.expand === nextProps.expand
    );
  },
);

const PostItemExpanded: React.FC<{
  item: Post;
  index: number;
}> = ({item, index}) => {
  console.log('PostItemExpanded', index);
  return (
    <View style={[postItemStyles.container]}>
      <Text style={postItemStyles.description}>{item.description}</Text>
      <Text style={postItemStyles.description}>{item.description}</Text>
      <Text style={postItemStyles.description}>{item.description}</Text>
      <Text style={postItemStyles.description}>{item.description}</Text>
      <Text style={postItemStyles.description}>{item.description}</Text>
      <Text style={postItemStyles.description}>{item.description}</Text>
      <Text style={postItemStyles.description}>{item.description}</Text>
    </View>
  );
};

const postItemStyles = StyleSheet.create({
  wrapper: {
    flexDirection: 'row',
  },
  container: {
    backgroundColor: '#fff',
    padding: 10,
    marginBottom: 1,
    flex: 1,
  },
  title: {
    fontSize: 16,
    fontWeight: 'bold',
  },
  description: {
    fontSize: 14,
    marginTop: 10,
  },
  expandButton: {
    backgroundColor: '#9ad0dc',
    padding: 10,
    justifyContent: 'center',
    alignItems: 'center',
  },
});

export const FlatListDemo = () => {
  const [postList, setPostList] = useState(postMocks);
  const [keyword, setKeyword] = useState('');
  const [order, setOrder] = useState<'ASC' | 'DESC'>('ASC');
  const [selectedIdList, setSelectedIdList] = useState<number[]>([]);
  const [expandedIdList, setExpandedIdList] = useState<number[]>([]);
  /**
   * create renderPostItem: => can reduce anonymous function in renderPostList
   * anonymous function will be created every time renderPostList is called => so it's better to create a function outside
   * @param param0
   * @returns
   */
  const renderPostItem = ({item, index}: {item: Post; index: number}) => {
    // alway re-render each time renderPostList re-render
    // to reduce re-render UI, we can use React.memo to create new component that only handle UI
    // check by append and remove post
    console.log('renderPostItem', index);
    const highlight = selectedIdList.includes(item.id);
    const expand = expandedIdList.includes(item.id);
    if (index % 2 === 1) {
      if (expand) {
        return <PostItemExpanded item={item} index={index} />;
      }

      return null;
    } else {
      return (
        <PostItem
          index={index}
          item={item}
          highlight={highlight}
          toggleSelect={() => {
            setSelectedIdList(curr => {
              const id = item.id;
              const newSelectedIdList = [...curr];
              const i = newSelectedIdList.indexOf(id);
              if (i === -1) {
                newSelectedIdList.push(id);
              } else {
                newSelectedIdList.splice(i, 1);
              }
              console.log('setSelectedIdList', curr, newSelectedIdList);
              return newSelectedIdList;
            });
          }}
          toggleExpand={() => {
            setExpandedIdList(curr => {
              const id = item.id;
              const newExpandedIdList = [...curr];
              const i = newExpandedIdList.indexOf(id);
              if (i === -1) {
                newExpandedIdList.push(id);
              } else {
                newExpandedIdList.splice(i, 1);
              }
              console.log('setExpandedIdList', curr, newExpandedIdList);
              return newExpandedIdList;
            });
          }}
          expand={expand}
        />
      );
    }
  };

  /**
   *
   * @param item
   * @returns
   */
  const keyExtractor = (item: Post, index: number) => `${item.id}-${index}`;

  const appendPost = () => {
    const newPost = {
      id: postList.length + 1,
      title: `Post ${postList.length + 1}`,
      description: `Description for Post ${postList.length + 1}`,
    };
    setPostList([...postList, newPost]);
  };

  const removeLastPost = () => {
    const newPostList = [...postList];
    newPostList.pop();
    setPostList(newPostList);
  };

  const toggleOrder = () => {
    const newOrder = order === 'ASC' ? 'DESC' : 'ASC';
    setOrder(newOrder);
  };

  const postListFiltered = postList.filter(post =>
    post.title.toLowerCase().includes(keyword.toLowerCase()),
  );
  const postListSorted = postListFiltered.sort((a, b) => {
    if (order === 'ASC') {
      return a.title.localeCompare(b.title);
    }
    return b.title.localeCompare(a.title);
  });

  const duplicateListSorted: Post[] = [];

  for (const post of postListSorted) {
    duplicateListSorted.push(post);
    duplicateListSorted.push(post);
  }

  const renderPostListHeader = () => {
    return (
      <>
        <TextInput
          value={keyword}
          onChangeText={setKeyword}
          style={postListHeaderStyles.input}
        />

        <TouchableOpacity style={styles.sortButton} onPress={toggleOrder}>
          <Text>
            Order: {order}. Total {postListSorted.length}. Selected{' '}
            {selectedIdList.length}. Expanded {expandedIdList.length}
          </Text>
        </TouchableOpacity>
      </>
    );
  };
  // stickyHeaderIndices = odd of duplicateListSorted
  const stickyHeaderIndices = duplicateListSorted
    .map((_, index) => index)
    .filter(index => index % 2 === 1);

  const renderPostList = () => {
    return (
      <FlatList
        style={postListStyles.container}
        data={duplicateListSorted}
        ListHeaderComponent={renderPostListHeader()}
        renderItem={renderPostItem}
        keyExtractor={keyExtractor}
        stickyHeaderIndices={stickyHeaderIndices}
      />
    );
  };
  return (
    <View style={styles.container}>
      <View style={styles.header}>
        {/* appendPost */}
        <TouchableOpacity onPress={appendPost} style={styles.button}>
          <Text>Append Post</Text>
        </TouchableOpacity>

        {/* removeLastPost */}
        <TouchableOpacity onPress={removeLastPost} style={styles.button}>
          <Text>Remove Last Post</Text>
        </TouchableOpacity>
      </View>
      {renderPostList()}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    borderTopColor: '#ddd',
    borderTopWidth: 1,
  },
  header: {
    backgroundColor: '#ddd',
    justifyContent: 'space-between',
    alignItems: 'center',
    flexDirection: 'row',
    padding: 10,
  },
  headerText: {
    fontSize: 16,
    fontWeight: '500',
  },
  button: {
    backgroundColor: '#fff',
    padding: 10,
    borderRadius: 5,
  },
  sortButton: {
    backgroundColor: '#d2d2d2',
    padding: 10,
    borderRadius: 5,
    borderBottomColor: '#ddd',
    borderBottomWidth: 1,
    alignItems: 'flex-end',
    marginHorizontal: 10,
  },
});

const postListStyles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f2f2f2',
  },
});

const postListHeaderStyles = StyleSheet.create({
  input: {
    backgroundColor: '#fff',
    padding: 10,
    margin: 10,
    borderRadius: 5,
  },
});
Enter fullscreen mode Exit fullscreen mode

Final Result

Issues

  • When stickyHeaderIndices update => flatlist will force update and re-render everything => this is cause an interrupt when you type => Not have any solution for it => Final result must remove stickyHeaderIndices

Oldest comments (0)