5-6 bo'lim mashqlari — yangiliklar ilovasi

~50 daq · React Native · Ma'lumot va API

Vazifa

Navigatsiya, API, kesh va holat boshqaruvini bitta ilovada birlashtiramiz. Ma'lumot manbai — bepul test API DummyJSON (https://dummyjson.com/posts).

Talablar:

  1. Ikki tab: Lenta va Saqlanganlar
  2. Lenta — cheksiz scroll (useInfiniteQuery), pull-to-refresh
  3. Postni bosish — /post/[id] ekrani: to'liq matn va izohlar (/posts/{id}/comments)
  4. Post ekranida "Saqlash" tugmasi — Saqlanganlar tabiga qo'shadi (Zustand + persist)
  5. Saqlanganlar tabi badge bilan, saqlanganlar soni
  6. Lenta tepasida qidiruv (/posts/search?q=...), 400 ms kechikish bilan (debounce)
  7. Offline banner; kesh diskda — ilova internetsiz ochilganda oxirgi lenta ko'rinadi
  8. Xato holatida — xabar va "Qayta urinish"

Tuzilma

app/
├── _layout.tsx               ← QueryClient, persist, Stack
├── (tabs)/
│   ├── _layout.tsx           ← Tabs + badge
│   ├── index.tsx             ← Lenta
│   └── saqlangan.tsx
└── post/[id].tsx
api/
└── postlar.ts                ← so'rovlar va hook lar
stores/
└── saqlangan.ts
hooks/
└── useDebounce.ts
components/
├── PostKarta.tsx
├── XatoEkran.tsx
└── OfflineBanner.tsx         ← 36-darsdan

api/postlar.ts
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';

const BASE = 'https://dummyjson.com';
const HAJM = 15;

export type Post = { id: number; title: string; body: string; tags: string[]; reactions: { likes: number }; views: number };
type PostlarJavob = { posts: Post[]; total: number; skip: number; limit: number };
export type Izoh = { id: number; body: string; user: { fullName: string } };

async function ol<T>(url: string): Promise<T> {
  const javob = await fetch(url);
  if (!javob.ok) throw new Error(`Server xatosi (${javob.status})`);
  return javob.json();
}

export function useLenta(qidiruv: string) {
  return useInfiniteQuery({
    queryKey: ['postlar', qidiruv],
    queryFn: ({ pageParam }) =>
      ol<PostlarJavob>(
        qidiruv
          ? `${BASE}/posts/search?q=${encodeURIComponent(qidiruv)}&limit=${HAJM}&skip=${pageParam}`
          : `${BASE}/posts?limit=${HAJM}&skip=${pageParam}`
      ),
    initialPageParam: 0,
    getNextPageParam: (oxirgi) => {
      const keyingi = oxirgi.skip + oxirgi.limit;
      return keyingi < oxirgi.total ? keyingi : undefined;
    },
  });
}

export function usePost(id: string) {
  return useQuery({ queryKey: ['post', id], queryFn: () => ol<Post>(`${BASE}/posts/${id}`), enabled: !!id });
}

export function useIzohlar(id: string) {
  return useQuery({
    queryKey: ['izohlar', id],
    queryFn: () => ol<{ comments: Izoh[] }>(`${BASE}/posts/${id}/comments`).then((d) => d.comments),
    enabled: !!id,
  });
}

encodeURIComponent — qidiruvdagi bo'sh joy va maxsus belgilar URL ni buzmasin.

stores/saqlangan.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Post } from '@/api/postlar';

type SaqlanganPost = Pick<Post, 'id' | 'title' | 'body'>;

type Holat = {
  postlar: SaqlanganPost[];
  almashtir: (p: SaqlanganPost) => void;
};

export const useSaqlangan = create<Holat>()(
  persist(
    (set) => ({
      postlar: [],
      almashtir: (p) =>
        set((s) => ({
          postlar: s.postlar.some((x) => x.id === p.id)
            ? s.postlar.filter((x) => x.id !== p.id)
            : [p, ...s.postlar],
        })),
    }),
    { name: 'saqlangan', storage: createJSONStorage(() => AsyncStorage) }
  )
);
hooks/useDebounce.ts
import { useEffect, useState } from 'react';

export function useDebounce<T>(qiymat: T, kechikish = 400) {
  const [kechiktirilgan, setKechiktirilgan] = useState(qiymat);
  useEffect(() => {
    const t = setTimeout(() => setKechiktirilgan(qiymat), kechikish);
    return () => clearTimeout(t);
  }, [qiymat, kechikish]);
  return kechiktirilgan;
}

Har bir harf uchun so'rov yubormaslik: foydalanuvchi yozishni to'xtatgandan 400 ms keyin.

app/(tabs)/index.tsx — Lenta
import { useState } from 'react';
import { FlatList, TextInput, View, ActivityIndicator, Text } from 'react-native';
import { useLenta } from '@/api/postlar';
import { useDebounce } from '@/hooks/useDebounce';
import { PostKarta } from '@/components/PostKarta';
import { XatoEkran } from '@/components/XatoEkran';

export default function Lenta() {
  const [qidiruv, setQidiruv] = useState('');
  const q = useDebounce(qidiruv.trim());
  const { data, isPending, isError, error, refetch, isRefetching, fetchNextPage, hasNextPage, isFetchingNextPage } = useLenta(q);

  const postlar = data?.pages.flatMap((p) => p.posts) ?? [];

  return (
    <View style={{ flex: 1 }}>
      <TextInput
        value={qidiruv}
        onChangeText={setQidiruv}
        placeholder="Qidirish..."
        style={{ margin: 12, padding: 12, backgroundColor: '#f1f5f9', borderRadius: 10, fontSize: 16 }}
        autoCorrect={false}
        clearButtonMode="while-editing"
      />

      {isPending ? (
        <ActivityIndicator size="large" style={{ marginTop: 40 }} />
      ) : isError ? (
        <XatoEkran xabar={error.message} qaytaUrinish={refetch} />
      ) : (
        <FlatList
          data={postlar}
          keyExtractor={(p) => String(p.id)}
          renderItem={({ item }) => <PostKarta post={item} />}
          contentContainerStyle={{ padding: 12, gap: 10 }}
          onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()}
          onEndReachedThreshold={0.5}
          refreshing={isRefetching && !isFetchingNextPage}
          onRefresh={refetch}
          keyboardDismissMode="on-drag"
          ListFooterComponent={isFetchingNextPage ? <ActivityIndicator style={{ margin: 16 }} /> : null}
          ListEmptyComponent={<Text style={{ textAlign: 'center', marginTop: 40, color: '#64748b' }}>Hech narsa topilmadi</Text>}
        />
      )}
    </View>
  );
}
components/PostKarta.tsx
import { memo } from 'react';
import { Link } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { Post } from '@/api/postlar';

export const PostKarta = memo(function PostKarta({ post }: { post: Post }) {
  return (
    <Link href={{ pathname: '/post/[id]', params: { id: post.id } }} asChild>
      <Pressable style={({ pressed }) => ({ backgroundColor: 'white', padding: 14, borderRadius: 12, gap: 6, opacity: pressed ? 0.8 : 1 })}>
        <Text style={{ fontSize: 16, fontWeight: '700' }} numberOfLines={2}>{post.title}</Text>
        <Text style={{ color: '#475569' }} numberOfLines={3}>{post.body}</Text>
        <View style={{ flexDirection: 'row', gap: 16 }}>
          <Text style={{ color: '#94a3b8', fontSize: 13 }}><Ionicons name="heart-outline" /> {post.reactions.likes}</Text>
          <Text style={{ color: '#94a3b8', fontSize: 13 }}><Ionicons name="eye-outline" /> {post.views}</Text>
        </View>
      </Pressable>
    </Link>
  );
});
app/post/[id].tsx
import { ScrollView, Text, View, ActivityIndicator, Pressable } from 'react-native';
import { Stack, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { usePost, useIzohlar } from '@/api/postlar';
import { useSaqlangan } from '@/stores/saqlangan';
import { XatoEkran } from '@/components/XatoEkran';

export default function PostEkran() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const post = usePost(id);
  const izohlar = useIzohlar(id);
  const saqlangan = useSaqlangan((s) => s.postlar.some((p) => p.id === Number(id)));
  const almashtir = useSaqlangan((s) => s.almashtir);

  if (post.isPending) return <ActivityIndicator size="large" style={{ flex: 1 }} />;
  if (post.isError) return <XatoEkran xabar={post.error.message} qaytaUrinish={post.refetch} />;

  const p = post.data;

  return (
    <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>
      <Stack.Screen
        options={{
          title: 'Post',
          headerRight: () => (
            <Pressable onPress={() => almashtir({ id: p.id, title: p.title, body: p.body })} hitSlop={10}
              accessibilityLabel={saqlangan ? 'Saqlanganlardan olib tashlash' : 'Saqlash'}>
              <Ionicons name={saqlangan ? 'bookmark' : 'bookmark-outline'} size={22} color="#0284c7" />
            </Pressable>
          ),
        }}
      />
      <Text style={{ fontSize: 22, fontWeight: '800' }}>{p.title}</Text>
      <Text style={{ fontSize: 16, lineHeight: 24, color: '#334155' }}>{p.body}</Text>
      <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
        {p.tags.map((t) => (
          <Text key={t} style={{ backgroundColor: '#e0f2fe', color: '#0369a1', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999 }}>#{t}</Text>
        ))}
      </View>

      <Text style={{ fontSize: 18, fontWeight: '700', marginTop: 12 }}>Izohlar</Text>
      {izohlar.isPending ? <ActivityIndicator /> :
        izohlar.data?.map((iz) => (
          <View key={iz.id} style={{ backgroundColor: '#f8fafc', padding: 12, borderRadius: 10 }}>
            <Text style={{ fontWeight: '600' }}>{iz.user.fullName}</Text>
            <Text style={{ color: '#475569', marginTop: 4 }}>{iz.body}</Text>
          </View>
        ))}
    </ScrollView>
  );
}

Izohlar kam (5-10 ta) — ScrollView + map normal (8-dars).

app/(tabs)/_layout.tsx — badge
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSaqlangan } from '@/stores/saqlangan';

export default function TabLayout() {
  const soni = useSaqlangan((s) => s.postlar.length);
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: '#0284c7' }}>
      <Tabs.Screen name="index" options={{ title: 'Lenta',
        tabBarIcon: ({ color, size, focused }) => <Ionicons name={focused ? 'newspaper' : 'newspaper-outline'} size={size} color={color} /> }} />
      <Tabs.Screen name="saqlangan" options={{ title: 'Saqlanganlar', tabBarBadge: soni || undefined,
        tabBarIcon: ({ color, size, focused }) => <Ionicons name={focused ? 'bookmark' : 'bookmark-outline'} size={size} color={color} /> }} />
    </Tabs>
  );
}

app/_layout.tsx — 36-darsdagi PersistQueryClientProvider + OfflineBanner + Stack ((tabs) sarlavhasiz, post/[id] sarlavha bilan). saqlangan.tsx — useSaqlangan dan FlatList, bo'sh holat matni bilan.


Qo'shimcha vazifalar

  1. ⭐ Saqlanganlar ekranida chapga surib o'chirish (46-darsdan keyin)
  2. ⭐⭐ Post ekranida "Yoqtirish" — optimistik yangilash bilan (dummyjson.com/posts/{id} ga PUT)
  3. ⭐⭐ Teg bosilganda — shu teg bo'yicha postlar ekrani (/posts/tag/{teg})
  4. ⭐⭐⭐ Planshetda — chapda lenta, o'ngda tanlangan post (15-dars)

O'zingizni tekshiring

  • push va replace farqi? Kirishdan keyin qaysi biri?
  • (tabs) papka nomidagi qavslar nimani bildiradi?
  • Deep link bilan kelgan parametrga nima uchun ishonmaslik kerak?
  • TanStack Query keshi foydalanuvchiga nima beradi?
  • Server holati va klient holati farqi? Ular qayerda saqlanadi?
  • Token ni nima uchun AsyncStorage da saqlamaslik kerak?

Keyingi bo'lim

7-bo'lim: Qurilma imkoniyatlari — ruxsatlar, kamera, galereya, joylashuv, push bildirishnomalar.