Passed
Push — master ( b25be8...d8e2f1 )
by Tristan
07:17 queued 10s
created

singleResourceHook.ts ➔ useResource   F

Complexity

Conditions 15

Size

Total Lines 95
Code Lines 75

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 75
dl 0
loc 95
rs 2.9181
c 0
b 0
f 0
cc 15

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like singleResourceHook.ts ➔ useResource often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
import { Reducer, useCallback, useEffect, useReducer, useRef } from "react";
2
import {
3
  FetchError,
4
  getRequest,
5
  processJsonResponse,
6
  putRequest,
7
} from "../../helpers/httpRequests";
8
import { decrement, identity } from "../../helpers/queries";
9
import { Json, ResourceStatus } from "./types";
10
11
export interface ResourceState<T> {
12
  value: T;
13
  status: ResourceStatus;
14
  pendingCount: number;
15
  error: Error | FetchError | undefined;
16
}
17
18
export enum ActionTypes {
19
  GetStart = "GET_START",
20
  GetFulfill = "GET_FULFILL",
21
  GetReject = "GET_REJECT",
22
23
  UpdateStart = "UPDATE_START",
24
  UpdateFulfill = "UPDATE_FULFILL",
25
  UpdateReject = "UPDATE_REJECT",
26
}
27
28
export type GetStartAction = { type: ActionTypes.GetStart };
29
export type GetFulfillAction<T> = {
30
  type: ActionTypes.GetFulfill;
31
  payload: T;
32
};
33
export type GetRejectAction = {
34
  type: ActionTypes.GetReject;
35
  payload: Error | FetchError;
36
};
37
38
export type UpdateStartAction<T> = {
39
  type: ActionTypes.UpdateStart;
40
  meta: { item: T };
41
};
42
export type UpdateFulfillAction<T> = {
43
  type: ActionTypes.UpdateFulfill;
44
  payload: T;
45
  meta: { item: T };
46
};
47
export type UpdateRejectAction<T> = {
48
  type: ActionTypes.UpdateReject;
49
  payload: Error | FetchError;
50
  meta: { item: T };
51
};
52
export type AsyncAction<T> =
53
  | GetStartAction
54
  | GetFulfillAction<T>
55
  | GetRejectAction
56
  | UpdateStartAction<T>
57
  | UpdateFulfillAction<T>
58
  | UpdateRejectAction<T>;
59
60
export function initialState<T>(initialValue: T): ResourceState<T> {
61
  return {
62
    value: initialValue,
63
    status: "initial",
64
    pendingCount: 0,
65
    error: undefined,
66
  };
67
}
68
69
export function reducer<T>(
70
  state: ResourceState<T>,
71
  action: AsyncAction<T>,
72
): ResourceState<T> {
73
  switch (action.type) {
74
    case ActionTypes.GetStart:
75
    case ActionTypes.UpdateStart: // TODO: For now GET and UPDATE actions can be treated the same. If we want to add optimistic updates, this can change.
76
      return {
77
        value: state.value,
78
        status: "pending",
79
        pendingCount: state.pendingCount + 1,
80
        error: undefined,
81
      };
82
    case ActionTypes.GetFulfill:
83
    case ActionTypes.UpdateFulfill:
84
      return {
85
        value: action.payload,
86
        status: state.pendingCount <= 1 ? "fulfilled" : "pending",
87
        pendingCount: decrement(state.pendingCount),
88
        error: undefined,
89
      };
90
    case ActionTypes.GetReject:
91
    case ActionTypes.UpdateReject:
92
      return {
93
        value: state.value,
94
        status: state.pendingCount <= 1 ? "rejected" : "pending",
95
        pendingCount: decrement(state.pendingCount),
96
        error: action.payload,
97
      };
98
    default:
99
      return state;
100
  }
101
}
102
103
function doNothing(): void {
104
  /* do nothing */
105
}
106
107
export type UseResourceReturnType<T> = {
108
  value: T;
109
  status: ResourceStatus;
110
  error: undefined | Error | FetchError;
111
  update: (newValue: T) => Promise<T>;
112
  refresh: () => Promise<T>;
113
};
114
115
export function useResource<T>(
116
  endpoint: string,
117
  initialValue: T,
118
  overrides?: {
119
    parseResponse?: (response: Json) => T; // Defaults to the identity function.
120
    skipInitialFetch?: boolean; // Defaults to false. Override if you want to keep the initialValue until refresh is called manually.
121
    handleError?: (error: Error | FetchError) => void; // In addition to using the error returned by the hook, you may provide a callback called on every new error.
122
  },
123
): UseResourceReturnType<T> {
124
  const parseResponse = overrides?.parseResponse ?? identity;
125
  const doInitialRefresh = overrides?.skipInitialFetch !== true;
126
  const handleError = overrides?.handleError ?? doNothing;
127
  const isSubscribed = useRef(true);
128
129
  const [state, dispatch] = useReducer<
130
    Reducer<ResourceState<T>, AsyncAction<T>>
131
  >(reducer, initialState(initialValue));
132
133
  const refresh = useCallback(async (): Promise<T> => {
134
    dispatch({ type: ActionTypes.GetStart });
135
    let json: Json;
136
    try {
137
      json = await getRequest(endpoint).then(processJsonResponse);
138
    } catch (error) {
139
      if (isSubscribed.current) {
140
        dispatch({
141
          type: ActionTypes.GetReject,
142
          payload: error,
143
        });
144
        handleError(error);
145
      }
146
      throw error;
147
    }
148
    const responseValue = parseResponse(json) as T;
149
    if (isSubscribed.current) {
150
      dispatch({
151
        type: ActionTypes.GetFulfill,
152
        payload: responseValue,
153
      });
154
    }
155
    return responseValue;
156
  }, [endpoint, parseResponse, handleError]);
157
158
  const update = useCallback(
159
    async (newValue): Promise<T> => {
160
      dispatch({ type: ActionTypes.UpdateStart, meta: { item: newValue } });
161
      let json: Json;
162
      try {
163
        json = await putRequest(endpoint, newValue).then(processJsonResponse);
164
      } catch (error) {
165
        if (isSubscribed.current) {
166
          dispatch({
167
            type: ActionTypes.UpdateReject,
168
            payload: error,
169
            meta: { item: newValue },
170
          });
171
          handleError(error);
172
        }
173
        throw error;
174
      }
175
      const responseValue = parseResponse(json) as T;
176
      if (isSubscribed.current) {
177
        dispatch({
178
          type: ActionTypes.UpdateFulfill,
179
          payload: responseValue,
180
          meta: { item: newValue },
181
        });
182
      }
183
      return responseValue;
184
    },
185
    [endpoint, parseResponse, handleError],
186
  );
187
188
  // Despite the usual guidlines, this should only be reconsidered if endpoint changes.
189
  // Changing doInitialRefresh after the first run (or the refresh function) should not cause this to rerun.
190
  useEffect(() => {
191
    if (doInitialRefresh) {
192
      refresh().catch(doNothing);
193
    }
194
195
    // Unsubscribe from promises when this hook is unmounted.
196
    return (): void => {
197
      isSubscribed.current = false;
198
    };
199
200
    // eslint-disable-next-line react-hooks/exhaustive-deps
201
  }, [endpoint]);
202
203
  return {
204
    value: state.value,
205
    status: state.status,
206
    error: state.error,
207
    update,
208
    refresh,
209
  };
210
}
211
212
export default useResource;
213